diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 87b67ff..8c2fb8d 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -35,3 +35,16 @@ jobs: run: | ./tests.sh + # Notify if any previous step in this job failed + - name: Notify on failure + if: ${{ failure() }} + env: + WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} + REPOSITORY: ${{ forgejo.repository }} + RUN_NUMBER: ${{ forgejo.run_number }} + SERVER_URL: ${{ forgejo.server_url }} + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ + "$WEBHOOK_URL" diff --git a/.forgejo/workflows/lint.yml b/.forgejo/workflows/lint.yml index 53ab3eb..fbe5a7e 100644 --- a/.forgejo/workflows/lint.yml +++ b/.forgejo/workflows/lint.yml @@ -15,7 +15,7 @@ jobs: run: | apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - black pyflakes3 vulture + black pyflakes3 vulture python3-bandit - name: Run linters run: | @@ -24,3 +24,18 @@ jobs: pyflakes3 bouquin/* pyflakes3 tests/* vulture + bandit -s B110 -r bouquin/ + + # Notify if any previous step in this job failed + - name: Notify on failure + if: ${{ failure() }} + env: + WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} + REPOSITORY: ${{ forgejo.repository }} + RUN_NUMBER: ${{ forgejo.run_number }} + SERVER_URL: ${{ forgejo.server_url }} + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ + "$WEBHOOK_URL" diff --git a/.forgejo/workflows/trivy.yml b/.forgejo/workflows/trivy.yml index 18ced32..fad2f6f 100644 --- a/.forgejo/workflows/trivy.yml +++ b/.forgejo/workflows/trivy.yml @@ -24,3 +24,17 @@ jobs: - name: Run trivy run: | trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry . + + # Notify if any previous step in this job failed + - name: Notify on failure + if: ${{ failure() }} + env: + WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} + REPOSITORY: ${{ forgejo.repository }} + RUN_NUMBER: ${{ forgejo.run_number }} + SERVER_URL: ${{ forgejo.server_url }} + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ + "$WEBHOOK_URL" diff --git a/.gitignore b/.gitignore index 2352872..07c956d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ __pycache__ .pytest_cache dist .coverage +*.db +*.pdf +*.csv +*.html diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6281daa --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,26 @@ +repos: + - repo: https://github.com/pycqa/flake8 + rev: 7.3.0 + hooks: + - id: flake8 + args: ["--select=F"] + types: [python] + + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 25.11.0 + hooks: + - id: black + language_version: python3 + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + + - repo: https://github.com/PyCQA/bandit + rev: 1.9.2 + hooks: + - id: bandit + files: ^bouquin/ + args: ["-s", "B110"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b35218..45edf09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,167 @@ +# 0.7.3 + + * Allow optionally moving unchecked TODOs to the next day (even if it's the weekend) rather than next weekday. + * Add 'group by activity' in timesheet/invoice reports, rather than just by time period. + +# 0.7.2 + + * Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders) + +# 0.7.1 + + * Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter) + * Moving unchecked TODO items to the next weekday now brings across the header that was above it, if present + * Invoicing should not be enabled by default + * Fix Reminders to fire right on the minute after adding them during runtime + * It is now possible to set up Webhooks for Reminders! A URL and a secret value (sent as X-Bouquin-Header) can be set in the Settings. + * Improvements to StatisticsDialog: it now shows statistics about logged time, reminders, etc. Sections are grouped for better readability + +# 0.7.0 + + * New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features. + * Add 'Last week' to Time Report dialog range option + * Add 'Change Date' button to the History Dialog (same as the one used in Time log dialogs) + +# 0.6.4 + + * Time reports: Fix report 'group by' logic to not show ambiguous 'note' data. + * Time reports: Add default option to 'don't group'. This gives every individual time log row (and so the 'note' is shown in this case) + * Reminders: Ability to explicitly set the date of a reminder and have it handle recurrence based on that date + +# 0.6.3 + + * Allow 'this week', 'this month', 'this year' granularity in Timesheet reports. Default date range to start from this month. + * Allow 'All Projects' for timesheet reports. + * Make Reminder alarm proposed time be 5 minutes into the future (no point in being right now - that time is already passed) + * Allow more advanced recurring reminders (fortnightly, every specific day of month, monthly on the Nth weekday) + +# 0.6.2 + + * Ensure that adding a document whilst on an older date page, uses that date as its upload date + * Add 'Created at' to time log table. + * Show total hours for the day in the time log table (not just in the widget in sidebar) + * Pomodoro timer is now in the sidebar when toggled on, rather than as a separate dialog, so it stays out of the way + * Indent tabs by 4 spaces in code block editor dialog + +# 0.6.1 + + * Consolidate some code related to opening documents using the Documents feature. + * Ensure time log dialog gets closed when Pomodoro Timer finishes and user logs time. + * More code coverage + +# 0.6.0 + + * Add 'Documents' feature. Documents are tied to Projects in the same way as Time Logging, and can be tagged via the Tags feature. + * Close time log dialog if opened via the + button from sidebar widget + * Only show tags in Statistics widget if tags are enabled + * Fix rounding up/down in Pomodoro timer to the closest 15 min interval + +# 0.5.5 + + * Add + button to time log widget in side bar to have a simplified log entry dialog (without summary or report option) + * Allow click-and-drag mouse select on lines with checkbox, to capture the checkbox as well as the text. + * Allow changing the date when logging time (rather than having to go to that date before clicking on adding time log/opening time log manager) + * Ensure time log reports have an extension + +# 0.5.4 + + * Ensure pressing enter a second time on a new line with a checkbox, erases the checkbox (if it had no text added to it) + +# 0.5.3 + + * Prevent triple-click select from selecting the list item (e.g checkbox, bullet) + * Use DejaVu Sans font for regular text instead of heavier Noto - might help with the freeze issues. + * Change History icon (again) + * Make it easier to check on or off the checkbox by adding some buffer (instead of having to precisely click inside it) + * Prevent double-click of checkbox leading to selecting/highlighting it + * Slightly fade the text of a checkbox line if the checkbox is checked. + * Fix weekend date colours being incorrect on theme change while app is running + * Avoid capturing checkbox/bullet etc in the task text that would get offered as the 'note' when Pomodoro timer stops + * Code Blocks are now their own QDialog to try and reduce risk of getting trapped in / bleeding in/out of text in code blocks. + +# 0.5.2 + + * Update icon again to remove background + * Adjust History icon and reorder toolbar items + * Try to address checkbox/bullet size issues (again) + * Fix HTML export of markdown (with newlines, tables and other styling preserved) + * Remove table tool + +# 0.5.1 + + * Try to address Noto Sans font issue that works for both numbers and checkbox/bullets. + * Update icon + * Update French translations + * Improve size of flashing reminder dialog + +# 0.5 + + * More Italian translations, thank you @mdaleo404 + * Set locked status on window title when locked + * Don't exit on incorrect key, let it be tried again + * Make reminders be its own dataset rather than tied to current string. + * Add support for repeated reminders + * Make reminders be a feature that can be turned on and off + * Add syntax highlighting for code blocks (right-click to set it) + * Add a Pomodoro-style timer for measuring time spent on a task (stopping the timer offers to log it to Time Log) + * Add ability to create markdown tables. Right-click to edit the table in a friendlier table dialog + +# 0.4.5 + + * Make it possible to delete revisions + * Make it possible to force-lock the screen even if idle timer hasn't tripped + * Add shortcuts for lock and unlock of screen + * Other misc bug fixes + +# 0.4.4.1 + + * Adjust some widget heights/settings text wrap + * Adjust shortcuts + * History unicode symbol + * Icon in version dialog + +# 0.4.4 + + * Moving unchecked TODOs now includes those up to 7 days ago, not just yesterday + * Moving unchecked TODOs now skips placing them on weekends. + * Moving unchecked TODOs now automatically occurs after midnight if the app is open (not just on startup) + * Check for new version / download new AppImage via the Help -> Version screen. + * Remove extra newline after headings + +# 0.4.3 + + * Ship Noto Sans Symbols2 font, which seems to work better for unicode symbols on Fedora + +# 0.4.2 + + * Improve Statistics widget height + * Improve SaveDialog widget width + * Make Tags and TimeLog optional features that can be switched on/off in Settings (enabled by default) + * Make it possible to change regular text size + * Refactored Settings dialog to use tabs to reduce its size + +# 0.4.1 + + * Allow time log entries to be edited directly in their table cells + * Miscellaneous bug fixes for editing (list cursor positions/text selectivity, alarm removing newline) + * Add 'Close tab' nav item and shortcut + +# 0.4 + + * Remove screenshot tool + * Improve width of bug report dialog + * Improve size of checkboxes + * Convert bullet - to actual unicode bullets + * Add alarm option to set reminders + * Add time logging and reporting + +# 0.3.2 + + * Add weekday letters on left axis of Statistics page + * Allow clicking on a date in the Statistics heatmap and have it open that page + * Add the ability to choose the database path at startup + * Add in-app bug report functionality + # 0.3.1 * Make it possible to add a tag from the Tag Browser diff --git a/README.md b/README.md index c8ffec2..da87442 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,16 @@ # Bouquin +
|
+ {logo_html}
+
+ {company_html}
+
+ |
+
+ {invoice_title}
+
|
+
|
+ BILL TO
+ {client_block}
+ |
+
+
+
{tax_summary_text}
+ |
+
| ITEMS AND DESCRIPTION | +QTY/HRS | +PRICE | +AMOUNT ({currency}) | +
|---|
|
+ PAYMENT DETAILS
+
+{payment_text}
+
+ |
+
+
+
|
+
{html.escape(strings._("time_report_total").format(hours=total_hours))}
+ + +""" + + # ---------- Render HTML to PDF ---------- + printer = QPrinter(QPrinter.HighResolution) + printer.setOutputFormat(QPrinter.PdfFormat) + printer.setOutputFileName(filename) + printer.setPageOrientation(QPageLayout.Orientation.Landscape) + + doc = QTextDocument() + # attach the chart image as a resource + doc.addResource(QTextDocument.ImageResource, QUrl("chart"), chart) + doc.setHtml(html_doc) + + try: + doc.print_(printer) + except Exception as exc: # very defensive + QMessageBox.warning( + self, + strings._("export_pdf_error_title"), + strings._("export_pdf_error_message").format(error=str(exc)), + ) + + def _update_invoice_button_state(self) -> None: + data = self.project_combo.currentData() + if data is not None: + self.invoice_btn.show() + else: + self.invoice_btn.hide() + + def _on_manage_invoices(self) -> None: + from .invoices import InvoicesDialog + + dlg = InvoicesDialog(self._db, parent=self) + + # When the dialog says "reminders changed", forward that outward + dlg.remindersChanged.connect(self.remindersChanged.emit) + + dlg.exec() + + def _on_create_invoice(self) -> None: + idx = self.project_combo.currentIndex() + if idx < 0: + return + + project_id_data = self.project_combo.itemData(idx) + if project_id_data is None: + # Currently invoices are per-project, not cross-project + QMessageBox.information( + self, + strings._("invoice_project_required_title"), + strings._("invoice_project_required_message"), + ) + return + + proj_id = int(project_id_data) + + # Ensure we have a recent run to base this on + if not self._last_time_logs: + QMessageBox.information( + self, + strings._("invoice_need_report_title"), + strings._("invoice_need_report_message"), + ) + return + + start = self.from_date.date().toString("yyyy-MM-dd") + end = self.to_date.date().toString("yyyy-MM-dd") + + from .invoices import InvoiceDialog + + dlg = InvoiceDialog(self._db, proj_id, start, end, self._last_time_logs, self) + dlg.remindersChanged.connect(self.remindersChanged.emit) + dlg.exec() diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 89999b8..92383e6 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -1,7 +1,7 @@ from __future__ import annotations -from PySide6.QtCore import Signal, Qt -from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QAction, QActionGroup, QFont, QFontDatabase, QKeySequence from PySide6.QtWidgets import QToolBar from . import strings @@ -18,6 +18,11 @@ class ToolBar(QToolBar): checkboxesRequested = Signal() historyRequested = Signal() insertImageRequested = Signal() + alarmRequested = Signal() + timerRequested = Signal() + documentsRequested = Signal() + fontSizeLargerRequested = Signal() + fontSizeSmallerRequested = Signal() def __init__(self, parent=None): super().__init__(strings._("toolbar_format"), parent) @@ -66,12 +71,22 @@ class ToolBar(QToolBar): self.actH3.setCheckable(True) self.actH3.setShortcut("Ctrl+3") self.actH3.triggered.connect(lambda: self.headingRequested.emit(14)) - self.actNormal = QAction("N", self) + self.actNormal = QAction("P", self) self.actNormal.setToolTip(strings._("toolbar_normal_paragraph_text")) self.actNormal.setCheckable(True) - self.actNormal.setShortcut("Ctrl+N") + self.actNormal.setShortcut("Ctrl+.") self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0)) + self.actFontSmaller = QAction("P-", self) + self.actFontSmaller.setToolTip(strings._("toolbar_font_smaller")) + self.actFontSmaller.setShortcut("Ctrl+Shift+-") + self.actFontSmaller.triggered.connect(self.fontSizeSmallerRequested) + + self.actFontLarger = QAction("P+", self) + self.actFontLarger.setToolTip(strings._("toolbar_font_larger")) + self.actFontLarger.setShortcut("Ctrl+Shift+=") + self.actFontLarger.triggered.connect(self.fontSizeLargerRequested) + # Lists self.actBullets = QAction("•", self) self.actBullets.setToolTip(strings._("toolbar_bulleted_list")) @@ -81,20 +96,37 @@ class ToolBar(QToolBar): self.actNumbers.setToolTip(strings._("toolbar_numbered_list")) self.actNumbers.setCheckable(True) self.actNumbers.triggered.connect(self.numbersRequested) - self.actCheckboxes = QAction("☐", self) + self.actCheckboxes = QAction("☑", self) self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes")) self.actCheckboxes.triggered.connect(self.checkboxesRequested) # Images - self.actInsertImg = QAction(strings._("images"), self) + self.actInsertImg = QAction("📸", self) self.actInsertImg.setToolTip(strings._("insert_images")) self.actInsertImg.setShortcut("Ctrl+Shift+I") self.actInsertImg.triggered.connect(self.insertImageRequested) # History button - self.actHistory = QAction(strings._("history"), self) + self.actHistory = QAction("↺", self) + self.actHistory.setToolTip(strings._("history")) self.actHistory.triggered.connect(self.historyRequested) + # Alarm / reminder + self.actAlarm = QAction("⏰", self) + self.actAlarm.setToolTip(strings._("toolbar_alarm")) + self.actAlarm.triggered.connect(self.alarmRequested) + + # Focus timer + self.actTimer = QAction("⌛", self) + self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer")) + self.actTimer.setCheckable(True) + self.actTimer.triggered.connect(self.timerRequested) + + # Documents + self.actDocuments = QAction("📁", self) + self.actDocuments.setToolTip(strings._("toolbar_documents")) + self.actDocuments.triggered.connect(self.documentsRequested) + # Set exclusive buttons in QActionGroups self.grpHeadings = QActionGroup(self) self.grpHeadings.setExclusive(True) @@ -126,10 +158,15 @@ class ToolBar(QToolBar): self.actH2, self.actH3, self.actNormal, + self.actFontSmaller, + self.actFontLarger, self.actBullets, self.actNumbers, self.actCheckboxes, self.actInsertImg, + self.actAlarm, + self.actTimer, + self.actDocuments, self.actHistory, ] ) @@ -146,14 +183,20 @@ class ToolBar(QToolBar): self._style_letter_button(self.actH1, "H1") self._style_letter_button(self.actH2, "H2") self._style_letter_button(self.actH3, "H3") - self._style_letter_button(self.actNormal, "N") + self._style_letter_button(self.actNormal, "P") + self._style_letter_button(self.actFontSmaller, "P-") + self._style_letter_button(self.actFontLarger, "P+") # Lists self._style_letter_button(self.actBullets, "•") self._style_letter_button(self.actNumbers, "1.") + self._style_letter_button(self.actCheckboxes, "☑") + self._style_letter_button(self.actAlarm, "⏰") + self._style_letter_button(self.actTimer, "⌛") + self._style_letter_button(self.actDocuments, "📁") # History - self._style_letter_button(self.actHistory, strings._("view_history")) + self._style_letter_button(self.actHistory, "↺") def _style_letter_button( self, diff --git a/bouquin/version_check.py b/bouquin/version_check.py new file mode 100644 index 0000000..5b62d02 --- /dev/null +++ b/bouquin/version_check.py @@ -0,0 +1,406 @@ +from __future__ import annotations + +import importlib.metadata +import os +import re +import subprocess # nosec +import tempfile +from importlib.resources import files +from pathlib import Path + +import requests +from PySide6.QtCore import QStandardPaths, Qt +from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap +from PySide6.QtSvg import QSvgRenderer +from PySide6.QtWidgets import QApplication, QMessageBox, QProgressDialog, QWidget + +from . import strings +from .settings import APP_NAME + +# Where to fetch the latest version string from +VERSION_URL = "https://mig5.net/bouquin/version.txt" + +# Name of the installed distribution according to pyproject.toml +# (used with importlib.metadata.version) +DIST_NAME = "bouquin" + +# Base URL where AppImages are hosted +APPIMAGE_BASE_URL = "https://git.mig5.net/mig5/bouquin/releases/download" + +# Where we expect to find the bundled public key, relative to the *installed* package. +GPG_PUBKEY_RESOURCE = ("bouquin", "keys", "mig5.asc") + + +class VersionChecker: + """ + Handles: + * showing the version dialog + * checking for updates + * downloading & verifying a new AppImage + + All dialogs use `parent` as their parent widget. + """ + + def __init__(self, parent: QWidget | None = None): + self._parent = parent + + # ---------- Version helpers ---------- # + + def _logo_pixmap(self, logical_size: int = 96) -> QPixmap: + """ + Render the SVG logo to a high-DPI-aware QPixmap so it stays crisp. + """ + svg_path = Path(__file__).resolve().parent / "icons" / "bouquin.svg" + + # Logical size (what Qt layouts see) + dpr = QGuiApplication.primaryScreen().devicePixelRatio() + img_size = int(logical_size * dpr) + + image = QImage(img_size, img_size, QImage.Format_ARGB32) + image.fill(Qt.transparent) + + renderer = QSvgRenderer(str(svg_path)) + painter = QPainter(image) + renderer.render(painter) + painter.end() + + pixmap = QPixmap.fromImage(image) + pixmap.setDevicePixelRatio(dpr) + return pixmap + + def current_version(self) -> str: + """ + Return the current app version as reported by importlib.metadata + """ + try: + return importlib.metadata.version(DIST_NAME) + except importlib.metadata.PackageNotFoundError: + # Fallback for editable installs / dev trees + return "0.0.0" + + @staticmethod + def _parse_version(v: str) -> tuple[int, ...]: + """ + Very small helper to compare simple semantic versions like 1.2.3. + Extracts numeric components and returns them as a tuple. + """ + parts = re.findall(r"\d+", v) + if not parts: + return (0,) + return tuple(int(p) for p in parts) + + def _is_newer_version(self, available: str, current: str) -> bool: + """ + True if `available` > `current` according to _parse_version. + """ + return self._parse_version(available) > self._parse_version(current) + + # ---------- Public entrypoint for Help → Version ---------- # + + def show_version_dialog(self) -> None: + """ + Show the Version dialog with a 'Check for updates' button. + """ + version = self.current_version() + version_formatted = f"{APP_NAME} {version}" + + box = QMessageBox(self._parent) + box.setWindowTitle(strings._("version")) + + box.setIconPixmap(self._logo_pixmap(96)) + + box.setText(version_formatted) + + check_button = box.addButton( + strings._("check_for_updates"), QMessageBox.ActionRole + ) + box.addButton(QMessageBox.Close) + + box.exec() + + if box.clickedButton() is check_button: + self.check_for_updates() + + # ---------- Core update logic ---------- # + + def check_for_updates(self) -> None: + """ + Fetch VERSION_URL, compare against the current version, and optionally + download + verify a new AppImage. + """ + current = self.current_version() + + try: + resp = requests.get(VERSION_URL, timeout=10) + resp.raise_for_status() + available_raw = resp.text.strip() + except Exception as e: + QMessageBox.warning( + self._parent, + strings._("update"), + strings._("could_not_check_for_updates") + str(e), + ) + return + + if not available_raw: + QMessageBox.warning( + self._parent, + strings._("update"), + strings._("update_server_returned_an_empty_version_string"), + ) + return + + if not self._is_newer_version(available_raw, current): + QMessageBox.information( + self._parent, + strings._("update"), + strings._("you_are_running_the_latest_version") + f"({current}).", + ) + return + + # Newer version is available + reply = QMessageBox.question( + self._parent, + strings._("update"), + ( + strings._("there_is_a_new_version_available") + + available_raw + + "\n\n" + + strings._("download_the_appimage") + ), + QMessageBox.Yes | QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + + self._download_and_verify_appimage(available_raw) + + # ---------- Download + verification helpers ---------- # + def _download_file( + self, + url: str, + dest_path: Path, + timeout: int = 30, + progress: QProgressDialog | None = None, + label: str | None = None, + ) -> None: + """ + Stream a URL to a local file, optionally updating a QProgressDialog. + If the user cancels via the dialog, raises RuntimeError. + """ + resp = requests.get(url, timeout=timeout, stream=True) + resp.raise_for_status() + + dest_path.parent.mkdir(parents=True, exist_ok=True) + + total_bytes: int | None = None + content_length = resp.headers.get("Content-Length") + if content_length is not None: + try: + total_bytes = int(content_length) + except ValueError: + total_bytes = None + + if progress is not None: + progress.setLabelText( + label or strings._("downloading") + f" {dest_path.name}..." + ) + # Unknown size → busy indicator; known size → real range + if total_bytes is not None and total_bytes > 0: + progress.setRange(0, total_bytes) + else: + progress.setRange(0, 0) # pragma: no cover + progress.setValue(0) + progress.show() + QApplication.processEvents() + + downloaded = 0 + with dest_path.open("wb") as f: + for chunk in resp.iter_content(chunk_size=8192): + if not chunk: + continue # pragma: no cover + + f.write(chunk) + downloaded += len(chunk) + + if progress is not None: + if total_bytes is not None and total_bytes > 0: + progress.setValue(downloaded) + else: + # Just bump a little so the dialog looks alive + progress.setValue(progress.value() + 1) # pragma: no cover + QApplication.processEvents() + + if progress.wasCanceled(): + raise RuntimeError(strings._("download_cancelled")) + + if progress is not None and total_bytes is not None and total_bytes > 0: + progress.setValue(total_bytes) + QApplication.processEvents() + + def _download_and_verify_appimage(self, version: str) -> None: + """ + Download the AppImage + its GPG signature to the user's Downloads dir, + then verify it with a bundled public key. + """ + # Where to put the file + download_dir = QStandardPaths.writableLocation(QStandardPaths.DownloadLocation) + if not download_dir: + download_dir = os.path.expanduser("~/Downloads") + download_dir = Path(download_dir) + download_dir.mkdir(parents=True, exist_ok=True) + + # Construct AppImage filename and URLs + appimage_path = download_dir / "Bouquin.AppImage" + sig_path = Path(str(appimage_path) + ".asc") + + appimage_url = f"{APPIMAGE_BASE_URL}/{version}/Bouquin.AppImage" + sig_url = f"{appimage_url}.asc" + + # Progress dialog covering both downloads + progress = QProgressDialog( + "Downloading update...", + "Cancel", + 0, + 100, + self._parent, + ) + progress.setWindowTitle(strings._("update")) + progress.setWindowModality(Qt.WindowModal) + progress.setAutoClose(False) + progress.setAutoReset(False) + + try: + # AppImage download + self._download_file( + appimage_url, + appimage_path, + progress=progress, + label=strings._("downloading") + " Bouquin.AppImage...", + ) + # Signature download (usually tiny, but we still show it) + self._download_file( + sig_url, + sig_path, + progress=progress, + label=strings._("downloading") + " signature...", + ) + except RuntimeError: + # User cancelled + for p in (appimage_path, sig_path): + try: + if p.exists(): + p.unlink() # pragma: no cover + except OSError: # pragma: no cover + pass + + progress.close() + QMessageBox.information( + self._parent, + strings._("update"), + strings._("download_cancelled"), + ) + return + except Exception as e: + # Other error + for p in (appimage_path, sig_path): + try: + if p.exists(): + p.unlink() # pragma: no cover + except OSError: # pragma: no cover + pass + + progress.close() + QMessageBox.critical( + self._parent, + strings._("update"), + strings._("failed_to_download_update") + str(e), + ) + return + + progress.close() + + # Load the bundled public key + try: + pkg, *rel = GPG_PUBKEY_RESOURCE + pubkey_bytes = (files(pkg) / "/".join(rel)).read_bytes() + except Exception as e: # pragma: no cover + QMessageBox.critical( + self._parent, + strings._("update"), + strings._("could_not_read_bundled_gpg_public_key") + str(e), + ) + # On failure, delete the downloaded files for safety + for p in (appimage_path, sig_path): + try: + if p.exists(): + p.unlink() + except OSError: # pragma: no cover + pass + return + + # Use a temporary GNUPGHOME so we don't touch the user's main keyring + try: + with tempfile.TemporaryDirectory() as gnupg_home: + pubkey_path = Path(gnupg_home) / "pubkey.asc" + pubkey_path.write_bytes(pubkey_bytes) + + # Import the key + subprocess.run( + ["gpg", "--homedir", gnupg_home, "--import", str(pubkey_path)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) # nosec + + # Verify the signature + subprocess.run( + [ + "gpg", + "--homedir", + gnupg_home, + "--verify", + str(sig_path), + str(appimage_path), + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) # nosec + except FileNotFoundError: + # gpg not installed / not on PATH + for p in (appimage_path, sig_path): + try: + if p.exists(): + p.unlink() # pragma: no cover + except OSError: # pragma: no cover + pass + + QMessageBox.critical( + self._parent, + strings._("update"), + strings._("could_not_find_gpg_executable"), + ) + return + except subprocess.CalledProcessError as e: + for p in (appimage_path, sig_path): + try: + if p.exists(): + p.unlink() # pragma: no cover + except OSError: # pragma: no cover + pass + + QMessageBox.critical( + self._parent, + strings._("update"), + strings._("gpg_signature_verification_failed") + + e.stderr.decode(errors="ignore"), + ) + return + + # Success + QMessageBox.information( + self._parent, + strings._("update"), + strings._("downloaded_and_verified_new_appimage") + str(appimage_path), + ) diff --git a/find_unused_strings.py b/find_unused_strings.py new file mode 100755 index 0000000..5341001 --- /dev/null +++ b/find_unused_strings.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 + +import argparse +import ast +import json +from pathlib import Path +from typing import Dict, Set + + +BASE_DIR = Path(__file__).resolve().parent / "bouquin" +LOCALES_DIR = BASE_DIR / "locales" + + +def load_json_keys(locale: str) -> Set[str]: + """Load all keys from the given locale JSON file.""" + path = LOCALES_DIR / f"{locale}.json" + with path.open(encoding="utf-8") as f: + data = json.load(f) + return set(data.keys()) + + +class KeyParamFinder(ast.NodeVisitor): + """ + First pass: + For each function/method, figure out which parameters are later passed + into _(), translated(), or strings._(). + + Example: in your _prompt_name, it discovers that title_key and label_key + are translation-key parameters. + """ + + def __init__(self) -> None: + # func_name -> {"param_positions": {param: arg_index}, "key_param_positions": set[arg_index]} + self.func_info: Dict[str, dict] = {} + self.current_func_name_stack: list[str] = [] + self.current_param_positions_stack: list[Dict[str, int]] = [] + self.current_class_stack: list[str] = [] + + # Track when we're inside a class so we can treat "self" specially + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self.current_class_stack.append(node.name) + self.generic_visit(node) + self.current_class_stack.pop() + + def _enter_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + funcname = node.name + params = [arg.arg for arg in node.args.args] + + # If we're inside a class and there is at least one param, + # assume the first one is "self"/"cls" and is implicit at call sites. + is_method = bool(self.current_class_stack) and len(params) > 0 + + param_positions: Dict[str, int] = {} + for i, name in enumerate(params): + if is_method and i == 0: + # skip self/cls; it doesn't correspond to an explicit arg in calls like self.method(...) + continue + call_index = i - 1 if is_method else i + param_positions[name] = call_index + + self.current_func_name_stack.append(funcname) + self.current_param_positions_stack.append(param_positions) + + self.func_info.setdefault( + funcname, + { + "param_positions": param_positions, + "key_param_positions": set(), + }, + ) + # If the function name is reused, last definition wins + self.func_info[funcname]["param_positions"] = param_positions + + def _exit_function(self) -> None: + self.current_func_name_stack.pop() + self.current_param_positions_stack.pop() + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._enter_function(node) + self.generic_visit(node) + self._exit_function() + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._enter_function(node) + self.generic_visit(node) + self._exit_function() + + def visit_Call(self, node: ast.Call) -> None: + # Only care about calls *inside* functions + if not self.current_func_name_stack: + return self.generic_visit(node) + + func = node.func + func_name: str | None = None + + if isinstance(func, ast.Name): + func_name = func.id + elif isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name): + # e.g. strings._(...) + func_name = f"{func.value.id}.{func.attr}" + + # Is this a translation call? + if func_name in {"_", "translated", "strings._"}: + cur_name = self.current_func_name_stack[-1] + param_positions = self.current_param_positions_stack[-1] + + # Positional first arg + if node.args: + first = node.args[0] + if isinstance(first, ast.Name): + pname = first.id + if pname in param_positions: + idx = param_positions[pname] + self.func_info[cur_name]["key_param_positions"].add(idx) + + # Keyword args, e.g. strings._(key=title_key) + for kw in node.keywords or []: + if isinstance(kw.value, ast.Name): + pname = kw.value.id + if pname in param_positions: + idx = param_positions[pname] + self.func_info[cur_name]["key_param_positions"].add(idx) + + self.generic_visit(node) + + +class UsedKeyCollector(ast.NodeVisitor): + """ + Second pass: + - Collect string literals passed directly to _()/translated()/strings._() + - Collect string literals passed into parameters that we know are + "translation-key parameters" of wrapper functions/methods. + """ + + def __init__(self, func_info: Dict[str, dict]) -> None: + self.func_info = func_info + self.used_keys: Set[str] = set() + + def visit_Call(self, node: ast.Call) -> None: + func = node.func + + def full_name(f: ast.expr) -> str | None: + if isinstance(f, ast.Name): + return f.id + if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name): + return f"{f.value.id}.{f.attr}" + return None + + func_full = full_name(func) + + # 1) Direct translation calls like _("key") or strings._("key") + if func_full in {"_", "translated", "strings._"}: + if node.args: + first = node.args[0] + if isinstance(first, ast.Constant) and isinstance(first.value, str): + self.used_keys.add(first.value) + for kw in node.keywords or []: + if isinstance(kw.value, ast.Constant) and isinstance( + kw.value.value, str + ): + self.used_keys.add(kw.value.value) + + # 2) Wrapper calls: functions whose params we know are translation-key params + called_base_name: str | None = None + if isinstance(func, ast.Name): + called_base_name = func.id + elif isinstance(func, ast.Attribute): + called_base_name = func.attr # e.g. self._prompt_name -> "_prompt_name" + + if called_base_name in self.func_info: + info = self.func_info[called_base_name] + param_positions: Dict[str, int] = info["param_positions"] + key_positions: Set[int] = info["key_param_positions"] + + # positional args + for idx, arg in enumerate(node.args): + if ( + idx in key_positions + and isinstance(arg, ast.Constant) + and isinstance(arg.value, str) + ): + self.used_keys.add(arg.value) + + # keyword args + for kw in node.keywords or []: + if kw.arg is None: + continue # **kwargs, ignore + param_name = kw.arg + if param_name in param_positions: + idx = param_positions[param_name] + if idx in key_positions: + val = kw.value + if isinstance(val, ast.Constant) and isinstance(val.value, str): + self.used_keys.add(val.value) + + self.generic_visit(node) + + +def collect_used_keys() -> Set[str]: + """Parse all .py files and collect all translation keys used.""" + trees: list[ast.AST] = [] + + # Read and parse all Python files in this folder + for path in BASE_DIR.glob("*.py"): + # Optionally skip this script itself + if path.name == Path(__file__).name: + continue + src = path.read_text(encoding="utf-8") + tree = ast.parse(src, filename=str(path)) + trees.append(tree) + + # First pass: find which parameters are translation-key params + finder = KeyParamFinder() + for tree in trees: + finder.visit(tree) + + # Second pass: collect string literals passed to those parameters + collector = UsedKeyCollector(finder.func_info) + for tree in trees: + collector.visit(tree) + + return collector.used_keys + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Find missing or unused strings for a given locale" + ) + parser.add_argument( + "--locale", + type=str, + default="en", + help="Locale key e.g en, fr, it", + ) + args = parser.parse_args() + + json_keys = load_json_keys(args.locale) + used_keys = collect_used_keys() + + unused_keys = sorted(json_keys - used_keys) + missing_in_json = sorted(used_keys - json_keys) + + print("=== Unused keys in JSON (present in locales but never used in code) ===") + if unused_keys: + for k in unused_keys: + print(" ", k) + else: + print(" (none)") + + print("\n=== Keys used in code but missing from JSON ===") + if missing_in_json: + for k in missing_in_json: + print(" ", k) + else: + print(" (none)") + + +if __name__ == "__main__": + main() diff --git a/poetry.lock b/poetry.lock index 6382937..115621c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,13 +2,13 @@ [[package]] name = "certifi" -version = "2025.10.5" +version = "2025.11.12" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" files = [ - {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, - {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, + {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, + {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, ] [[package]] @@ -146,115 +146,103 @@ files = [ [[package]] name = "coverage" -version = "7.10.7" +version = "7.13.0" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, - {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, - {file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, - {file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"}, - {file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, - {file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, - {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, - {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, - {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, - {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, - {file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, - {file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, - {file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, - {file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, - {file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, - {file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"}, - {file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, - {file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, - {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, - {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, - {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, - {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, - {file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, - {file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, - {file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, - {file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, - {file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, - {file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, - {file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"}, - {file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, - {file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, - {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, - {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, - {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, - {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, - {file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, - {file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, - {file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, - {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, - {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, - {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, - {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, - {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, - {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, - {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, - {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, - {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, - {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, - {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, - {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, - {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, - {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, - {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, - {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, - {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, - {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, - {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, - {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, - {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, - {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, - {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, - {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, - {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, - {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, - {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, - {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, - {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, - {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, - {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, - {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, - {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, - {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, - {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, - {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, - {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, - {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, - {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, - {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, - {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, - {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, - {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, - {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, - {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, - {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, - {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, - {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, - {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, - {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, - {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, - {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, - {file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, - {file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, - {file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"}, - {file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"}, - {file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"}, - {file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"}, - {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"}, - {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"}, - {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"}, - {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"}, - {file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"}, - {file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"}, - {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, - {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, + {file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"}, + {file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"}, + {file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"}, + {file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"}, + {file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"}, + {file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"}, + {file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"}, + {file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"}, + {file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"}, + {file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"}, + {file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"}, + {file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"}, + {file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"}, + {file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"}, + {file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"}, + {file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"}, + {file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"}, + {file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"}, + {file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"}, + {file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"}, + {file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"}, + {file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"}, + {file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"}, + {file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"}, + {file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"}, + {file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"}, + {file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"}, + {file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"}, + {file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"}, + {file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"}, + {file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"}, + {file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"}, + {file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"}, + {file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"}, + {file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"}, + {file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"}, ] [package.dependencies] @@ -265,27 +253,27 @@ toml = ["tomli"] [[package]] name = "desktop-entry-lib" -version = "3.2" +version = "5.0" description = "A library for working with .desktop files" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "desktop-entry-lib-3.2.tar.gz", hash = "sha256:12189249f86dde52d055ac28897ad1ed14ef965407a50fb3ad4ac6cb1a7e8cde"}, - {file = "desktop_entry_lib-3.2-py3-none-any.whl", hash = "sha256:600748b2aab2cafbb3ebc08b5c1ded024d66e0756868b6d26b5aff44d336c4b5"}, + {file = "desktop_entry_lib-5.0-py3-none-any.whl", hash = "sha256:e60a0c2c5e42492dbe5378e596b1de87d1b1c4dc74d1f41998a164ee27a1226f"}, + {file = "desktop_entry_lib-5.0.tar.gz", hash = "sha256:9a621bac1819fe21021356e41fec0ac096ed56e6eb5dcfe0639cd8654914b864"}, ] [package.extras] -test = ["pyfakefs", "pytest", "pytest-cov"] +xdg-desktop-portal = ["jeepney"] [[package]] name = "exceptiongroup" -version = "1.3.0" +version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, - {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, ] [package.dependencies] @@ -310,15 +298,30 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2 [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" files = [ - {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, - {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "markdown" +version = "3.10" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.10" +files = [ + {file = "markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c"}, + {file = "markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e"}, +] + +[package.extras] +docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +testing = ["coverage", "pyyaml"] + [[package]] name = "packaging" version = "25.0" @@ -377,57 +380,57 @@ tomli = {version = "*", markers = "python_version < \"3.11\""} [[package]] name = "pyside6" -version = "6.10.0" +version = "6.10.1" description = "Python bindings for the Qt cross-platform application and UI framework" optional = false -python-versions = "<3.14,>=3.9" +python-versions = "<3.15,>=3.9" files = [ - {file = "pyside6-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:c2cbc5dc2a164e3c7c51b3435e24203e90e5edd518c865466afccbd2e5872bb0"}, - {file = "pyside6-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ae8c3c8339cd7c3c9faa7cc5c52670dcc8662ccf4b63a6fed61c6345b90c4c01"}, - {file = "pyside6-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:9f402f883e640048fab246d36e298a5e16df9b18ba2e8c519877e472d3602820"}, - {file = "pyside6-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:70a8bcc73ea8d6baab70bba311eac77b9a1d31f658d0b418e15eb6ea36c97e6f"}, - {file = "pyside6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:4b709bdeeb89d386059343a5a706fc185cee37b517bda44c7d6b64d5fdaf3339"}, + {file = "pyside6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:d0e70dd0e126d01986f357c2a555722f9462cf8a942bf2ce180baf69f468e516"}, + {file = "pyside6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4053bf51ba2c2cb20e1005edd469997976a02cec009f7c46356a0b65c137f1fa"}, + {file = "pyside6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:7d3ca20a40139ca5324a7864f1d91cdf2ff237e11bd16354a42670f2a4eeb13c"}, + {file = "pyside6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9f89ff994f774420eaa38cec6422fddd5356611d8481774820befd6f3bb84c9e"}, + {file = "pyside6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:9c5c1d94387d1a32a6fae25348097918ef413b87dfa3767c46f737c6d48ae437"}, ] [package.dependencies] -PySide6_Addons = "6.10.0" -PySide6_Essentials = "6.10.0" -shiboken6 = "6.10.0" +PySide6_Addons = "6.10.1" +PySide6_Essentials = "6.10.1" +shiboken6 = "6.10.1" [[package]] name = "pyside6-addons" -version = "6.10.0" +version = "6.10.1" description = "Python bindings for the Qt cross-platform application and UI framework (Addons)" optional = false -python-versions = "<3.14,>=3.9" +python-versions = "<3.15,>=3.9" files = [ - {file = "pyside6_addons-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:88e61e21ee4643cdd9efb39ec52f4dc1ac74c0b45c5b7fa453d03c094f0a8a5c"}, - {file = "pyside6_addons-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:08d4ed46c4c9a353a9eb84134678f8fdd4ce17fb8cce2b3686172a7575025464"}, - {file = "pyside6_addons-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:15d32229d681be0bba1b936c4a300da43d01e1917ada5b57f9e03a387c245ab0"}, - {file = "pyside6_addons-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:99d93a32c17c5f6d797c3b90dd58f2a8bae13abde81e85802c34ceafaee11859"}, - {file = "pyside6_addons-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:92536427413f3b6557cf53f1a515cd766725ee46a170aff57ad2ff1dfce0ffb1"}, + {file = "pyside6_addons-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:4d2b82bbf9b861134845803837011e5f9ac7d33661b216805273cf0c6d0f8e82"}, + {file = "pyside6_addons-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:330c229b58d30083a7b99ed22e118eb4f4126408429816a4044ccd0438ae81b4"}, + {file = "pyside6_addons-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:56864b5fecd6924187a2d0f7e98d968ed72b6cc267caa5b294cd7e88fff4e54c"}, + {file = "pyside6_addons-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:b6e249d15407dd33d6a2ffabd9dc6d7a8ab8c95d05f16a71dad4d07781c76341"}, + {file = "pyside6_addons-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:0de303c0447326cdc6c8be5ab066ef581e2d0baf22560c9362d41b8304fdf2db"}, ] [package.dependencies] -PySide6_Essentials = "6.10.0" -shiboken6 = "6.10.0" +PySide6_Essentials = "6.10.1" +shiboken6 = "6.10.1" [[package]] name = "pyside6-essentials" -version = "6.10.0" +version = "6.10.1" description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)" optional = false -python-versions = "<3.14,>=3.9" +python-versions = "<3.15,>=3.9" files = [ - {file = "pyside6_essentials-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:003e871effe1f3e5b876bde715c15a780d876682005a6e989d89f48b8b93e93a"}, - {file = "pyside6_essentials-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1d5e013a8698e37ab8ef360e6960794eb5ef20832a8d562e649b8c5a0574b2d8"}, - {file = "pyside6_essentials-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:b1dd0864f0577a448fb44426b91cafff7ee7cccd1782ba66491e1c668033f998"}, - {file = "pyside6_essentials-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:fc167eb211dd1580e20ba90d299e74898e7a5a1306d832421e879641fc03b6fe"}, - {file = "pyside6_essentials-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:6dd0936394cb14da2fd8e869899f5e0925a738b1c8d74c2f22503720ea363fb1"}, + {file = "pyside6_essentials-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:cd224aff3bb26ff1fca32c050e1c4d0bd9f951a96219d40d5f3d0128485b0bbe"}, + {file = "pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e9ccbfb58c03911a0bce1f2198605b02d4b5ca6276bfc0cbcf7c6f6393ffb856"}, + {file = "pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ec8617c9b143b0c19ba1cc5a7e98c538e4143795480cb152aee47802c18dc5d2"}, + {file = "pyside6_essentials-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9555a48e8f0acf63fc6a23c250808db841b28a66ed6ad89ee0e4df7628752674"}, + {file = "pyside6_essentials-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:4d1d248644f1778f8ddae5da714ca0f5a150a5e6f602af2765a7d21b876da05c"}, ] [package.dependencies] -shiboken6 = "6.10.0" +shiboken6 = "6.10.1" [[package]] name = "pytest" @@ -531,147 +534,153 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "shiboken6" -version = "6.10.0" +version = "6.10.1" description = "Python/C++ bindings helper module" optional = false -python-versions = "<3.14,>=3.9" +python-versions = "<3.15,>=3.9" files = [ - {file = "shiboken6-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:7a5f5f400ebfb3a13616030815708289c2154e701a60b9db7833b843e0bee543"}, - {file = "shiboken6-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e612734da515d683696980107cdc0396a3ae0f07b059f0f422ec8a2333810234"}, - {file = "shiboken6-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:b01377e68d14132360efb0f4b7233006d26aa8ae9bb50edf00960c2a5f52d148"}, - {file = "shiboken6-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:0bc5631c1bf150cbef768a17f5f289aae1cb4db6c6b0c19b2421394e27783717"}, - {file = "shiboken6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61"}, + {file = "shiboken6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:9f2990f5b61b0b68ecadcd896ab4441f2cb097eef7797ecc40584107d9850d71"}, + {file = "shiboken6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4221a52dfb81f24a0d20cc4f8981cb6edd810d5a9fb28287ce10d342573a0e4"}, + {file = "shiboken6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:c095b00f4d6bf578c0b2464bb4e264b351a99345374478570f69e2e679a2a1d0"}, + {file = "shiboken6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:c1601d3cda1fa32779b141663873741b54e797cb0328458d7466281f117b0a4e"}, + {file = "shiboken6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:5cf800917008587b551005a45add2d485cca66f5f7ecd5b320e9954e40448cc9"}, ] [[package]] name = "sqlcipher3-wheels" -version = "0.5.5.post0" +version = "0.5.6" description = "DB-API 2.0 interface for SQLCipher 3.x" optional = false python-versions = "*" files = [ - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:836cff85673ab9bdfe0f3e2bc38aefddb5f3a4c0de397b92f83546bb94ea38aa"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3fde9076a8810d19044f65fdfeeee5a9d044176ce91adc2258c8b18cb945474"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ad3ccb27f3fc9260b1bcebfd33fc5af1c2a1bf6a50e8e1bf7991d492458b438"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94bb8ab8cf7ae3dc0d51dcb75bf242ae4bd2f18549bfc975fd696c181e9ea8ca"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df0bf0a169b480615ea2021e7266e1154990762216d1fd8105b93d1fee336f49"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79cc1af145345e9bd625c961e4efc8fc6c6eefcaec90fbcf1c6b981492c08031"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d8b9f1c6d283acc5a0da16574c0f7690ba5b14cb5935f3078ccf8404a530075"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:952a23069a149a192a5eb8a9e552772b38c012825238175bc810f445a3aa8000"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24f1a57a4aa18d9ecd38cfce69dd06e58cfb521151a8316e18183e603e7108f4"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6568c64adc55f9882ba36c11a446810bd5d4c03796aab8ecb9024f3bca9eb2cd"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:26c2b58d2f2a9dd23ad4c310fb6c0f0c82ca4f36a0d4177a70f0efeb332798ee"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46827ffc7e705c5ecdf23ec69f56dd55b20857dc3c3c4893e360de8a38b4e708"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4033bbe2f0342936736ce7b8b2626f532509315576d5376764b410deae181cad"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win32.whl", hash = "sha256:bfb26dbba945860427bd3f82c132e6d2ef409baa062d315b952dd5a930b25870"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win_amd64.whl", hash = "sha256:168270b8fb295314aa4ee9f74435ceee42207bd16fe908f646a829b3a9daedad"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win_arm64.whl", hash = "sha256:1f1bb2c4c6defa812eb0238055a283cf3c2f400e956a57c25cf65cbdbac6783f"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:55d557376a90f14baf0f35e917f8644c3a8cf48897947fcd7ecf51d490dd689f"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f1739264a971901088fe1670befb8a8a707543186c8eecc58158ca287e309b2"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:920e19345b6c5335b61e9fbed2625f96dbc3b0269ab5120baeae2c9288f0be01"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6462734f6d0703f863f5968419d229de75bbf2a829f762bfb257b6df2355f977"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c397305b65612da76f254c692ff866571aa98fd3817ed0e40fce6d568d704966"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf3467fe834075b58215c50f9db7355ef86a73d256ac8ba5fffb8c946741a5dc"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a76d783c095a3c95185757c418e3bad3eab69cbf986970d422cce5431e84d7f5"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bf8d78895ee0f04dc525942a1f40796fa7c3d7d7fb36c987f55c243ce34192d"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d583a10dbe9a1752968788c2d6438461ec7068608ceaa72e6468d80727c3152e"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8c3156b39bb8f24dfbe17a49126d8fa404b00c01d7aa84e64a2293db1dae1a38"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:15c3cf77b31973aa008174518fa439d8620a093964c2d9edcb8d23d543345839"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f5743db0f3492359c2ab3a56b6bed00ecba193f2c75c74e8e3d78a45f1eb7c95"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cc40a213a3633f19c96432304a16f0cff7c4aeca1a3d2042d4be36e576e64a70"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win32.whl", hash = "sha256:433456ce962ae50887d6428d55bad46e5748a2cdd3d036180eb0bcdbe8bae9f9"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win_amd64.whl", hash = "sha256:ca4332b1890cc4f80587be8bd529e20475bd3291e07f11115b1fc773947b264a"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win_arm64.whl", hash = "sha256:a4634300cb2440baf17a78d6481d10902de4a2a6518f83a5ab2fe081e6b20b42"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8df43c11d767c6aac5cc300c1957356a9fd1b25f1946891003cf20a0146241"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:797653f08ecffcef2948dfd907fb20dab402d9efde6217c96befafc236e96c5b"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dca428fde0d1a522473f766465324d6539d324f219f4f7c159a3eb0d4f9983c5"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48e97922240a04b44637eabf39f86d243fe61fe7db1bd2ad219eb4053158f263"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8d3a366e52a6732b1ccff14f9ca77ecbee53abfce87c417bf05d4301484584f"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dce28a2431260251d7acf253ea1950983e48dfec64245126b39a770d5a88f507"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea64cce27152cae453c353038336bda0dc1f885e5e8e30b5cd28b8c9b498bbeb"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02d9e6120a496f083c525efc34408d4f2ca282da05bebcc967a0aa1e12a0d6ca"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:875cfc61bbf694b8327c2485e5ed40573e8b715f4e583502f12c51c8d5a92dd5"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0e9ed3ff9c00ba3888f8dbc0c7c84377ef66f21c5f4ac373fc690dcf5e9bd594"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:08ad6d767502429e497b6d03b5ae11e43e896d36f05ac8e60c12d8f124378bc1"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ecdefdcf7ab8cb14b3147a59af83e8e3e5e3bed46fc43ab86a657f5c306a83d2"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:75ffe5677407bf20a32486eb6facfbb07a353ce7c9aecc9fefd4e9d3275605d7"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win32.whl", hash = "sha256:97b6c6556b430b5c0dff53e8f709f90ba53294c2a3958a8c38f573c6dbf467d9"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win_amd64.whl", hash = "sha256:248cae211991f1ffb3a885a1223e62abee70c6c208fc2224a8dbf73d4e825baa"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win_arm64.whl", hash = "sha256:5a49fc3a859a53fd025dc2fa08410292d267018897fc63198de6c92860fa8be7"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2f798e1591fa5ba14d9da08a54f18e7000fd74973cde12eb862a3928a69b7996"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:369011b8dc68741313a8b77bb68a70b76052390eaf819e4cd6e13d0acbea602d"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5658990462a728c1f4b472d23c1f7f577eb2bced5bbbf7c2b45158b8340484bd"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:907a166e4e563da67fe22c480244459512e32d3e00853b3f1e6fdb9da6aa2da6"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ba972405e9f16042e37cbcb4fef47248339c8410847390d41268bd45dc3f6ca"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b5f1b380fe3b869f701f9d2a8c09e9edfeec261573c8bb009a3336717260d65"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2978c9d964ad643b0bc61e19d8d608a515ff270e9a2f1f6c5aeb8ad56255def"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29457feb1516a2542aa7676e6d03bf913191690bf1ed6c82353782a380388508"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:021af568414741a45bfca41d682da64916a7873498a31d896cc34ad540939c6b"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7471d12eef489eea60cc3806bae0690f6d0733f7aea371a3ad5c5642f3bc04a9"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0f5faf683db9ade192a870e28b1eeeec2eb0aeca18e88fa52195a4639974c7cb"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:64fe6bac67d2b807b91102eef41d7f389e008ded80575ba597b454e05f9522e5"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:63ef1e23eb9729e79783e2ab4b19f64276c155ba0a85ba1eeb21e248c6ce0956"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win32.whl", hash = "sha256:4eafde00138dd3753085b4f5eab0811247207b699de862589f886a94ad3628a4"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win_amd64.whl", hash = "sha256:909864f275460646d0bf5475dc42e9c2cadd79cd40805ea32fe9a69300595301"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win_arm64.whl", hash = "sha256:a831846cc6b01d7f99576efbf797b61a269dffa6885f530b6957573ce1a24f10"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:aaad03f3eb099401306fead806908c85b923064a9da7a99c33a72c3b7c9143bf"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74d4b4cde184d9e354152fd1867bcbaee468529865703ad863840a0ce4eb60cd"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac3ad3cf9e1d0f08e8d22a65115368f2b22b9e96403fa644e146e1221c65c454"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45c4d3dca4acdbc5543bb00aee1e0715db797aa2819db5b7ca3feed3ab3366ff"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf7026851ea60d63c1a88f62439da78b68bfbfec192c781255e3cfb34b6efc12"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7ae4a83678c41c2cdbf3c2b18fc46be32225260c7b4807087bdb43793ee90fa"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:678c67d60b35eced29777fe9398b6e6a6638156f143c80662a0c7c99ce115be7"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:9830af5aef2c17686d6e7c78c20b92c7b57c9d7921a03e4c549b48fe0e98c5c0"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:08651e17c868a6a22124b6ab71e939a5bb4737e0535f381ce35077dc8116c4b3"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_s390x.whl", hash = "sha256:b58b4c944d7ce20cd7b426ae8834433b5b1152391960960b778b37803f0ffc1c"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:2b38818468ddb0c8fc4b172031d65ced3be22ba82360c45909a0546b2857d3e4"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-win32.whl", hash = "sha256:91d1f2284d13b68f213d05b51cd6242f4cfa065d291af6f353f9cbedd28d8c0d"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-win_amd64.whl", hash = "sha256:a5c724f95366ba7a2895147b0690609b6774384fa8621aa46a66cf332e4b612f"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e37b33263fad4accdba45c8566566d45fc01f47fd4afa3e265df9e0e3581d4f4"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f1e29495bc968e37352c315d23a38462d7e77fcfa1597d005d17ed93f9f3103"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad8a774f545eb5471587e0389fca4f855f36d58901c65547796d59fc13aee458"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a5a0251e4ceca127b26d18f0965b7f3c820a2dd2c51c25015c819300fd5859"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:853e40535f0f57e8b605e1cbce1399c96bcd5ab99e60992d2c7669c689d0cbe5"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e04e1dd62d019cde936d18fcd21361f6c4695e0e73fd6dc509c4ccd9446d26d"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:2df377e3d04f5c427c9f79ef95bdf0b982bde76c1dbd4441f83268f3f1993a53"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:5f3cb8db19e7462ccb2e34b56feaccb2aac675ad8f77e28f8222b3e7c47d1f92"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:b40860b3e6d6108473836a29d3600e1b335192637e16e9421b43b58147ced3c1"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:ca4fd9e8b669e81bb74479bde61ee475d7a6832d667e2ce80e6136ddd7a0fedd"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:771e74a22f48c40b6402d0ca1d569ced5a796e118d4472da388744b5aa0ebd3f"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-win32.whl", hash = "sha256:4589bfca18ecf787598262327f7329fe1f4fc2655c04899d84451562e2099a57"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-win_amd64.whl", hash = "sha256:f646ab958a601bad8925a876f5aa68bdf0ec3584630143ed1ad8e9df4e447044"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dfb8106a05af1cb1eadeea996171b52c80f18851972e49ffe91539e4fc064b0f"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b8b9b77a898b721fc634858fc43552119d3d303485adc6f28f3e76f028d5ea04"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c65efab1a0ab14f75314039694ac35d3181a5c8cf43584bd537b36caf2a6ccf9"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b450eee7e201c48aae58e2d45ef5d309a19cd49952cfb58d546fefbeef0a100"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8f0997202d7628c4312f0398122bdc5ada7fa79939d248652af40d9da689ef8"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dae69bef7628236d426e408fb14a40f0027bac1658a06efd29549b26ba369372"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef48e874bcc3ebf623672ec99f9aaa7b8a4f62fb270e33dad6db3739ea111086"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9006dc1a73e2b2a53421aa72decbcff08cb109f67a20f7d15a64ab140e0a1d2"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:96c07a345740fa71c0d8fc5fa7ea182ee24f62ebbf33d4d10c8c72d866dc332d"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:85a78af6f6e782e0df36f93c9e7c2dd568204f60a2ea55025c21d1837dea95ec"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:19eadc55bf69f9e9799a808cdcfc6657cf30511cb32849235d555adfa048a99f"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:377e8ad3bb3c17c43f860b570fd15e048246ade92babc9b310f2c417500aca57"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f8e07aec529d6fa31516201c524b0cfac108a9a6044a148f236291aae7991195"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-win32.whl", hash = "sha256:703ab55b77b1c1ebb80eb0b27574a8eadf109739d252de7f646dc41cb82f1a65"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-win_amd64.whl", hash = "sha256:b4f4b2e019c6d1ad33d5fc3163d31d0f731a073a5a52cdfae7b85408548ce342"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a84e3b098a29b8c298b01291cf8bc850a507ca45507d43674a84a8d33b7595b2"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9905b580cfdbd6945e44d81332483deace167d33e956ffae5c4b27eddeb676e7"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e0c403a7418631dc7185ef8053acc765101f4f64cc0bf50d1bc44ae7d40fc28e"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d63f89bf28de4ce82a7c324275ce733bf31eb29ec1121e48261af89b5b7f30b"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc6dc782f5be4883279079c79fa88578258a0fd24651f6d69b0f4be2716f7d7e"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743c177822c60e66c5c9579b4f384bd98e60fd4a2abe0eacdec0af4747d925bc"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec5eeb87220e4d2abf6faad1ecb3b3ee88c4d9caad6cf2ce4c0a73a91c4c7ad9"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9318b814363b4bc062e54852ea62f58b69e7da9e51211afd6c55e9170e1ae9a0"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e7a1f58a2614f2ad9fcb4822f6da56313cbb88309880512bf5d01bd3d9142b87"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ae9427ddde670f605c84f704c12d840628467cc0f0a8e9ce6489577eef6a0479"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:be75ae311433254af3c6fe6eb68bf80ac6ef547ec0cf4594f5b301a044682186"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:73e1438437bbe67d453e2908b90b17b357a992a9ac0011ad20af1ea7c2b4cd58"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:393c0a9af080c1c1a0801cca9448eff3633dafc1a7934fdce58a8d1c15d8bd2b"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win32.whl", hash = "sha256:13a79fc8e9b69bf6d70e7fa5a53bd42fab83dc3e6e93da7fa82871ec40874e43"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win_amd64.whl", hash = "sha256:b1648708e5bf599b4455bf330faa4777c3963669acb2f3fa25240123a01f8b2b"}, - {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win_arm64.whl", hash = "sha256:4c3dd2f54bdd518b90e28b07c31cdfe34c8bd182c5107a30a9c2ef9569cd6cf9"}, - {file = "sqlcipher3_wheels-0.5.5.post0.tar.gz", hash = "sha256:2c291ba05fa3e57c9b4d407d2751aa69266b5372468e7402daaa312b251aca7f"}, + {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e16c8caf59e86589fb5f52253420db07121f1f96e2a12e244f6fdcaf8b946530"}, + {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:337f2e059114729dd1529ee356c98e2aa06440d6a9772917514a3bda0647c61c"}, + {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f6bd900658446e1cdeebda0760adb9a89f55888b460623db88b100845cb51bc2"}, + {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:dc6fcca569858145cb5ba3c878997d1788973e36f689090178f807b9a44d9ca6"}, + {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:eef50cc39554ad1fb82faa33d25c7f3cb11e2f7087b41109bc169db2c942f0c7"}, + {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:0fc36fc67f639a0e03cf6f7c6a5d1bc5cdd8005e8e07da3b21c54d4d81ed353b"}, + {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:53d0b861668d6847c7cc0dc7b443263b95a5cd211bcc326a457bd3122ebbb5a0"}, + {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:10aef293397a4ab25d8346ba5f96181214ab9c6a8836d83320cf23a2ad773a2c"}, + {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1105e7edba36a29625a824bff0eca3685c1cf6e391182b85a9a73b4b1604eef3"}, + {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5db9b4035e42a27672abbe75120908c74a235a496cd92b4c685fda1e95e9b19c"}, + {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f9e3fb5e96c5067a8cfd7b2fa7d939e529e30439058bbc15d0e9adca5e4cff1b"}, + {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6f3c1a8a4a2c04225f5159cf7f1c315101a89271afbaef4205c6fc50766c5535"}, + {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fc0504a1dbe6d478614ef55eb80d0c02ead24bc91f34b41c07d404452389f42d"}, + {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-win32.whl", hash = "sha256:05ef2b35f176e3b29092ec9aa03b09f4803feddbabdc2174e7ccc608758f2beb"}, + {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-win_amd64.whl", hash = "sha256:0f6873e4badf64eb8c5771c9e8a726df46ac663bc8051dfefb51fe2a46358b37"}, + {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-win_arm64.whl", hash = "sha256:9fd30c1cffa10f63f504a33494564efc0e0a475bbf069487016a9d2462d115e5"}, + {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a6c511bacd40ba769368b1abbf97fbefb285f525e6d2a399a704c22ba2aae37f"}, + {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa25610cda2b2a1b1cefddbd93488e939cf0059480f2fda5a8704acddd0e8935"}, + {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5a5258fb99e99b6fda6f011a0a4094ff99fe2e9b9ac7ce81cf646e0e779829a3"}, + {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:459836d52904fa006bf36e2144959bd21577c32947fdd173db50b037108a8620"}, + {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:5b36f9949f4d35c72f0626aaac109b17688c1d6a9a6e11de2538b4cfc32cfad0"}, + {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:87301b545556a1811780bb6fc6480ab1f2640d1d5b5e5e33ed404559ae383647"}, + {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:fcc4705b5b7bd3508d08a6389a45e14591071a3e575c2864c9c1c615df89e0da"}, + {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:0a231eb677a8246c47e423c710198631850c0a090e8f02a7fb1ad266ba517c56"}, + {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:71ef871c65ad7c61048acb4f57da29bc0d5e35874183006222c229b5f1f64c73"}, + {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c3480298c9bc4117207535636fe74b01b4860ecd74a028c73b42f5f0ddaa8661"}, + {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d48cf218ed13f17e3037564f08fba7ddf2c260dac7993e3d4ac58ee30483f115"}, + {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ff57a80904b9bd55e18774cb59bffacad06e196298381ee576ce683d1c09b032"}, + {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50978685717cd9293ff5508c192695a894879f9faed5142d0e8d7b63310f87c2"}, + {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-win32.whl", hash = "sha256:24207dbb699ca68fc5fc7248385fdf33a92fb1e17a6ea88d3cf2345a18fb29ff"}, + {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-win_amd64.whl", hash = "sha256:40b1f8188a0aa5bbec354a12561b014b43a6a0d0a0d230a8a9378ed7b826b0ec"}, + {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-win_arm64.whl", hash = "sha256:107ef02bbd0f2ffb39a564c14ebf3bedfa4569949a0d72ec8e106f754d715b7c"}, + {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:59a572b18d1ef8318e9f583a7b3e1a67b4b04ed4b783c3f29fa806635274d12a"}, + {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32dfb8903b24db5879b1f922114f650bc6a15df9d071c55eefeb6937e13b2d20"}, + {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f5770257736c43cbf910a22f74c1490ef1ecde0432e475904f038e64ffdacb0"}, + {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c33f99ddfe08c0f34807046800e510316b8bac2974b3c5fb9ecb1ee25c391ac8"}, + {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:97d4c000deeb72c2421f555f3e55a8c161ddfb0499caabf60df2bfde6460a5fc"}, + {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:67d9889028b4adfcaecd32e1e60330e1764c209ad12438f0eec2a5145ebf4a2d"}, + {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:00cf178b15da486ab43ee2bed41edb1b393c5cfe2a48cae68893a2b31260dbd3"}, + {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:95bfa4c5ffdd72d9d8676c913d585b7885a42824824cf1d9e93d3669f01492dd"}, + {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:030ab50a8f4153cfe8dd5c98724909b210243af2350b9c79914838905a99518e"}, + {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5dc3c3d9deea654f8ea9c1dbc7bc90561331e4da9c7055381fac6498ca7267a3"}, + {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8cc986e8aa89e5a4a30b4eb8fd841d913a4e22ada99ec42be83f69bde3d86a31"}, + {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a41f0d30fa63d8db915566ec6987e68f064d96052cd6492ed8384b3e4807e60b"}, + {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f32fefe8a41e68334c545465813782fd45ef5cfe1082d012d95514c8a78e8015"}, + {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-win32.whl", hash = "sha256:ac2332f44758794a2fa19c77b824853e2a57ce5c27cc71c61066a52845be22d0"}, + {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:6f016ba5a2a531938f332a234865dfc25d3a69abc169c3bf1d5c06c3c3f24601"}, + {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:101ce0f7403801b6988d1f6c94244900e0f6c5378666e0ffd74b300687a6f9ef"}, + {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:94527fa3994c0fa1275c23d9fbb02512aacc675f1e45f566c660f4f9d5376e75"}, + {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0920a4b24362522ba83b36a47495d174221361213207191c325749a621fabeca"}, + {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5061b07b121ebd76aa697755b1b8f642cc3a27a0f6d392180ab249b35f1c2394"}, + {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:79de8511bb1fec62128e1b366cdc0cbd2ad1d725f3e29f9c91e96946a3c67945"}, + {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4b92c2f35bb8153cc20bcfc651536f51cc1194403782c542a852497ac789cbe2"}, + {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2d55211e3d2addff8a2df7335927d7fe6d75aa9ed12b396a22a5a0bfe2773ed9"}, + {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:8cb31de5d67799cc2bba92f23adc10281d66c2c16ca6418b94d80500a164aa60"}, + {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:123796de3e471db5ed8b4ee4f97ec562ad38347ad678dad71133eade280202e0"}, + {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b6d34fabacfad4f301a22b5d8466d7ee3481f735bdb327d8756f04c81d3516c4"}, + {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:91b02fc765485c5b65f2a3eacfd2e16059253e007d0b5a5f24bba5fcea9032dd"}, + {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:13db7f23c553ffdd35f6e3b26415bdb9f100dcf89038873965caef769e8f1af5"}, + {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4ba79a81cd591d32a3a225e3e9b50a9871324d0e414fb6d0866049d8820e4e46"}, + {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f97be07997681ca90fb339d5411fcb957bd7cbe810389404baed207cb366badd"}, + {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-win32.whl", hash = "sha256:9e56e0a7aa778da3d46323fc1233da5dcede795a6c7fe4c11980fec0ce8c3fe3"}, + {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:744845e4aa3cc614590f967aa1d38cc5d549177a2a83ed68c1821b5fb0505f8a"}, + {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:c92de0b940533ca3a5b43a45d0768e0698b6ca95020b2fd47ec269b6bfc228d1"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a3f558df797aabf51680b3fbce48c4b3df89c36ad7fcaa3886b2ed8057aa2786"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7e216586720663960c82f046c495ef6d828e8e95c8fcf4c767b555fb9b8feead"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3e4ef70d3af8ebe6ababe8eff93b8bd4ad288d0a38ab29a2420c91d636fbfe14"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:11e34aac6cb7e29d23e339c5de9e87700ddf09886e104640578b5afb566a2c50"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:79e220312a075546e6be0a6062dda6315857b1478d78f97eb352f1383dde8ce2"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:b953af7b57867bcffeeab59681921671615ae4b42fd0a9234ad0be7e0e43dfd4"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:130ac318dbcb3a51a4377b0bf3e450c6c21d508a8b00d2d9d4b3ee6a46ab3595"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5154c8022e58722987522ddce30f19fb69d6f8f6314959100d9f37c3dc5cba5b"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f91d1f5b7b927aa00a8d83724c58875d9d0e47bd81ca40445090ab521b5fa"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e1c140bfa6b0a7e08f414f2a9f8f529f7d8c4cfa8386ce588e6c747c4ccc6615"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:18fc56dfb32c6ce370d929897205027f78275c32446d6b1be712d462789ae8c2"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c03ec5e058fbf3fd94ecd8e0448834e8e7f46418eaec5fe5c7a0982c6e62c13f"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08756c1b25aebabb25a55dfe6f323876caea0c69511e34553807ae1d7ab843dd"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-win32.whl", hash = "sha256:bdbc58d224d27c002aed8a6361b43f3651943ecbfac69cd2674bbe681cf83790"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:dcc313f4519922c1ec3406b010d53f700750c1cf5331b9633a3c8b196307e852"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:dc1f0c77cc0395680176913a1d634a4014a1ebf02e7a7b2ac03a180b44241842"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fc30e82d2b8f139ac1ab81a3b3d9a59da8e3ce3b1e753285727480667efd5417"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f11d1d2c41141dd95f7d45f03dbe9f69a6427463e69db50609d83c0cd29980b5"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:92beff11fd9683941de7b47b8fc280e834b135ba7966d139b0ce2159b551ebad"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3060403647df7d44844c2808a384e4c4cf4a2a1b65e509a8016aca971c08ad39"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:9380de7e8fc952f376c9dae9ba1cdbb6a24ff5e41fd8f3b3cf39f1e305ed3248"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:9a26be381b0fb1c8d4fcdfd48182c78217ae9458513e4fe51b5045d4f94d41cb"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_s390x.whl", hash = "sha256:c3be08f8d81372a6d084062f969f88be0b942ac449b0ac01825b853c12705421"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c5bd4abbebc15f8a2a9a653500cd1abeb3aac13887fcc83de31ca40fce32e3a2"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4bb3c2c8e9a1e16455b989b2c7598b8053029bcbb519dc22601fa82bc8896f89"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:aac8ca9d2b4e18637e61ea1d8193500a1186f0b113b9224dc74186190f41c8e7"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f237a41c3f08e69f2532aec29a2589097baa73886164537d90c744d3d2eb3b3"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e6e59c3e0301cb04351b1cb12231aaadb40f56f779fb50a7857c6b4ed4c57297"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba2296a608081f4474f4447658a1e032d0b5506153baf68233471afde1463da9"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-win32.whl", hash = "sha256:8c8edfbd38a49ebbec2d1d56a000a499da2ac80b00488c156a1e0b8a7b8c10c6"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:21df85bc14d5d86225c1e7466ff65cbcc10f0d1d4f466823b4534c4c0564554c"}, + {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-win_arm64.whl", hash = "sha256:64df3e807fb0e6d89c1e90ce7c900bb82b695c474e1a0945a5f92862cac8b63d"}, + {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3784b22a29e4a675b456ca6ff1841d61e0eb97a28d0ba23d3d8cb5fe6da88238"}, + {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e27881be24f03d8a67a6db763f5671aaa05205de2380b1793b5e20bdabe49fba"}, + {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:667b6eec50ed03111676a0f4565be133643c9ad8bc88e6eea1c96b2af590c417"}, + {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:4eaaa5cf77b125e05908b1200681e2988b1a6a307c7e677967053a1e4b07fba5"}, + {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:4ead5b8f2607718548c8571e4a89fe735dd53443a2b5e42d8147eecd11b0d94b"}, + {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_ppc64le.whl", hash = "sha256:d82a8a7b478d23368320ad185533d063ec14d11a1d188f07ace513a66bfa9580"}, + {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_s390x.whl", hash = "sha256:39d871ee8c13d9b0326b24a02e5af21a7b1c8fb5e6f6f4ec62b935392202ec69"}, + {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:5a8737683621c2917a4ee9ff774e597a368c5b3d23f08ae53897d6bd1f8bfc0e"}, + {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:08b6922d5020384fa641c8dc416f6f2b143110c86dcf3aae086e7ce15b192eae"}, + {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f9dcc7f830ec56c090884a83be265c51c0a4fd60bb033b000c69c3bee08d77d8"}, + {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:0848f628b1528dd6a19a36679d8cde4b6f1f8d288757ba2e3df5578b79d79e90"}, + {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:1476bb15586ce27ea5fae7c54469b2be4efe51ca9cefa20871a6c394a18892cc"}, + {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:de17d373d9e7807236013950f598bf59b9ed7c375938fdb95378a7114e55ff95"}, + {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-win32.whl", hash = "sha256:02fa9e7f98a8e9be871219014b9ac015ba630b51615d90a2c06d45547a4b0cf1"}, + {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-win_amd64.whl", hash = "sha256:6b2d7daab225c578aec8109fde99624f281b4ccdc6c53c8cd8feb86d8e7d3cf2"}, + {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:abef5e28b4d1ca518291a8ca27af1cf9e4d68dd4a264d83874ec4d0a69589395"}, + {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cd4c12a5a60cbd533ba4a3b4131d23302283ba597739c7867066b4efefe178db"}, + {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b7672837f1b9a6a67e375b743d74371d0428ead79ff367591145d06f3711c96"}, + {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:61c33e2697b0d91f3cbe806104e1d5b93961d3ab55ba55ee53bb36efe83c9933"}, + {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:2e6eb09782dd719a1bb34af6e5ef25e5713c1f806231b472fcf64eb9288957af"}, + {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:6469b756ced0293e74806db2f114e5307cd4b05a559e986d3cc0b2eeb1eb8153"}, + {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:b6492f9bcb9296ac2179b5c9f7e7f329449b580836c0e8e5cfc2f3fe9af3486c"}, + {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2e4968d98917309463f02e4a48abebd95ed3d37968346f2693ed8a08e2fe9794"}, + {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:50214729697a1ee9e7603ba62b8ea46d78903ae1332caaa94fbaedde113944b7"}, + {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1ec9fd1dd5774d665903b8ba2e3e4f8ed72879dc42f6e9b2815040f0cb2d8ccd"}, + {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ced8ab30d205c8b6225b5703885576e629266767b091158731ec76c8c490bef4"}, + {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:3c7242a267dd802fee273084a5707a95d02df4102afbea133c8f716234c7edcc"}, + {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a6c239d15085af4b0f3433fa274c1fc37369509b99a7c035a359d5142a0536d"}, + {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-win32.whl", hash = "sha256:cc29963df04a73d8420a4d023ba016c9013d86378969d8a11fe2148477282936"}, + {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-win_amd64.whl", hash = "sha256:38cc7bb3a371c4a5fe7f4236a409e64f1286796d780833243f9e15ef852f159d"}, + {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-win_arm64.whl", hash = "sha256:186e49af3ddb98d260b95d436eaf58f2125712c268c8475627129c1f80a68164"}, + {file = "sqlcipher3_wheels-0.5.6.tar.gz", hash = "sha256:1d232c14be44db95a7f3018433cae01ecd18803fa2468fce3cc45ebd5e034942"}, ] [[package]] @@ -738,22 +747,22 @@ files = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.2" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, + {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, + {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +zstd = ["backports-zstd (>=1.0.0)"] [metadata] lock-version = "2.0" -python-versions = ">=3.9,<3.14" -content-hash = "8c65ccc55e84371f8695117dcd01ca9ad2d78b159327045eced824e5f425a7d0" +python-versions = ">=3.10,<3.14" +content-hash = "86db2b4d6498ce9eba7fa9aedf9ce58fec6a63542b5f3bdb3cf6c4067e5b87df" diff --git a/pyproject.toml b/pyproject.toml index 6b057a1..521e3d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,20 @@ [tool.poetry] name = "bouquin" -version = "0.3" +version = "0.7.3" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel JacqHTML paragraph
+""" + result = fresh_db._strip_markdown(text) + assert "My Document" in result + assert "paragraph" in result + assert "const x" not in result + assert "https://example.com" not in result + assert "" not in result
+
+
+def test_db_count_words_simple(fresh_db):
+ """Test word counting on simple text."""
+ text = "This is a simple test with seven words"
+ count = fresh_db._count_words(text)
+ assert count == 8
+
+
+def test_db_count_words_empty(fresh_db):
+ """Test word counting on empty text."""
+ count = fresh_db._count_words("")
+ assert count == 0
+
+
+def test_db_count_words_with_markdown(fresh_db):
+ """Test word counting strips markdown first."""
+ text = "**Bold** and *italic* and `code` words"
+ count = fresh_db._count_words(text)
+ # Should count: Bold, and, italic, and, words (5 words, code is in backticks so stripped)
+ assert count == 5
+
+
+def test_db_count_words_with_unicode(fresh_db):
+ """Test word counting with unicode characters."""
+ text = "Hello 世界 café naïve résumé"
+ count = fresh_db._count_words(text)
+ # Should count all words including unicode
+ assert count >= 5
+
+
+def test_db_count_words_with_numbers(fresh_db):
+ """Test word counting includes numbers."""
+ text = "There are 123 apples and 456 oranges"
+ count = fresh_db._count_words(text)
+ assert count == 7
+
+
+def test_db_count_words_with_punctuation(fresh_db):
+ """Test word counting handles punctuation correctly."""
+ text = "Hello, world! How are you? I'm fine, thanks."
+ count = fresh_db._count_words(text)
+ # Hello, world, How, are, you, I, m, fine, thanks = 9 words
+ assert count == 9
+
+
+# ============================================================================
+# DB gather_stats Tests
+# ============================================================================
+
+
+def test_db_gather_stats_empty_database(fresh_db):
+ """Test gather_stats on empty database."""
+ stats = fresh_db.gather_stats()
+
+ assert len(stats) == 22
+ (
+ pages_with_content,
+ total_revisions,
+ page_most_revisions,
+ page_most_revisions_count,
+ words_by_date,
+ total_words,
+ unique_tags,
+ page_most_tags,
+ page_most_tags_count,
+ revisions_by_date,
+ time_minutes_by_date,
+ total_time_minutes,
+ day_most_time,
+ day_most_time_minutes,
+ project_most_minutes_name,
+ project_most_minutes,
+ activity_most_minutes_name,
+ activity_most_minutes,
+ reminders_by_date,
+ total_reminders,
+ day_most_reminders,
+ day_most_reminders_count,
+ ) = stats
+
+ assert pages_with_content == 0
+ assert total_revisions == 0
+ assert page_most_revisions is None
+ assert page_most_revisions_count == 0
+ assert len(words_by_date) == 0
+ assert total_words == 0
+ assert unique_tags == 0
+ assert page_most_tags is None
+ assert page_most_tags_count == 0
+ assert len(revisions_by_date) == 0
+
+
+def test_db_gather_stats_with_content(fresh_db):
+ """Test gather_stats with actual content."""
+ # Add multiple pages with different content
+ fresh_db.save_new_version("2024-01-01", "Hello world this is a test", "v1")
+ fresh_db.save_new_version(
+ "2024-01-01", "Hello world this is version two", "v2"
+ ) # 2nd revision
+ fresh_db.save_new_version("2024-01-02", "Another page with more words here", "v1")
+
+ stats = fresh_db.gather_stats()
+
+ (
+ pages_with_content,
+ total_revisions,
+ page_most_revisions,
+ page_most_revisions_count,
+ words_by_date,
+ total_words,
+ unique_tags,
+ page_most_tags,
+ page_most_tags_count,
+ revisions_by_date,
+ *_rest,
+ ) = stats
+
+ assert pages_with_content == 2
+ assert total_revisions == 3
+ assert page_most_revisions == "2024-01-01"
+ assert page_most_revisions_count == 2
+ assert total_words > 0
+ assert len(words_by_date) == 2
+
+
+def test_db_gather_stats_word_counting(fresh_db):
+ """Test that gather_stats counts words correctly."""
+ # Add page with known word count
+ fresh_db.save_new_version("2024-01-01", "one two three four five", "test")
+
+ stats = fresh_db.gather_stats()
+ _, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats
+
+ assert total_words == 5
+
+ test_date = date(2024, 1, 1)
+ assert test_date in words_by_date
+ assert words_by_date[test_date] == 5
+
+
+def test_db_gather_stats_with_tags(fresh_db):
+ """Test gather_stats with tags."""
+ # Add tags
+ fresh_db.add_tag("tag1", "#ff0000")
+ fresh_db.add_tag("tag2", "#00ff00")
+ fresh_db.add_tag("tag3", "#0000ff")
+
+ # Add pages with tags
+ fresh_db.save_new_version("2024-01-01", "Page 1", "test")
+ fresh_db.save_new_version("2024-01-02", "Page 2", "test")
+
+ fresh_db.set_tags_for_page(
+ "2024-01-01", ["tag1", "tag2", "tag3"]
+ ) # Page 1 has 3 tags
+ fresh_db.set_tags_for_page("2024-01-02", ["tag1"]) # Page 2 has 1 tag
+
+ stats = fresh_db.gather_stats()
+ _, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, *_rest = stats
+
+ assert unique_tags == 3
+ assert page_most_tags == "2024-01-01"
+ assert page_most_tags_count == 3
+
+
+def test_db_gather_stats_revisions_by_date(fresh_db):
+ """Test revisions_by_date tracking."""
+ # Add multiple revisions on different dates
+ fresh_db.save_new_version("2024-01-01", "First", "v1")
+ fresh_db.save_new_version("2024-01-01", "Second", "v2")
+ fresh_db.save_new_version("2024-01-01", "Third", "v3")
+ fresh_db.save_new_version("2024-01-02", "Fourth", "v1")
+
+ stats = fresh_db.gather_stats()
+ _, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
+
+ assert date(2024, 1, 1) in revisions_by_date
+ assert revisions_by_date[date(2024, 1, 1)] == 3
+ assert date(2024, 1, 2) in revisions_by_date
+ assert revisions_by_date[date(2024, 1, 2)] == 1
+
+
+def test_db_gather_stats_handles_malformed_dates(fresh_db):
+ """Test that gather_stats handles malformed dates gracefully."""
+ # This is hard to test directly since the DB enforces date format
+ # But we can test that normal dates work
+ fresh_db.save_new_version("2024-01-15", "Test", "v1")
+
+ stats = fresh_db.gather_stats()
+ _, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
+
+ # Should have parsed the date correctly
+ assert date(2024, 1, 15) in revisions_by_date
+
+
+def test_db_gather_stats_current_version_only(fresh_db):
+ """Test that word counts use current version only, not all revisions."""
+ # Add multiple revisions
+ fresh_db.save_new_version("2024-01-01", "one two three", "v1")
+ fresh_db.save_new_version("2024-01-01", "one two three four five", "v2")
+
+ stats = fresh_db.gather_stats()
+ _, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats
+
+ # Should count words from current version (5 words), not old version
+ assert total_words == 5
+ assert words_by_date[date(2024, 1, 1)] == 5
+
+
+def test_db_gather_stats_no_tags(fresh_db):
+ """Test gather_stats when there are no tags."""
+ fresh_db.save_new_version("2024-01-01", "No tags here", "test")
+
+ stats = fresh_db.gather_stats()
+ _, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, *_rest = stats
+
+ assert unique_tags == 0
+ assert page_most_tags is None
+ assert page_most_tags_count == 0
+
+
+def test_db_gather_stats_exception_in_dates_with_content(fresh_db, monkeypatch):
+ """Test that gather_stats handles exception in dates_with_content."""
+
+ def bad_dates():
+ raise RuntimeError("Simulated error")
+
+ monkeypatch.setattr(fresh_db, "dates_with_content", bad_dates)
+
+ # Should still return stats without crashing
+ stats = fresh_db.gather_stats()
+ pages_with_content = stats[0]
+
+ # Should default to 0 when exception occurs
+ assert pages_with_content == 0
+
+
+def test_delete_version(fresh_db):
+ """Test deleting a specific version by version_id."""
+ d = date.today().isoformat()
+
+ # Create multiple versions
+ vid1, _ = fresh_db.save_new_version(d, "version 1", "note1")
+ vid2, _ = fresh_db.save_new_version(d, "version 2", "note2")
+ vid3, _ = fresh_db.save_new_version(d, "version 3", "note3")
+
+ # Verify all versions exist
+ versions = fresh_db.list_versions(d)
+ assert len(versions) == 3
+
+ # Delete the second version
+ fresh_db.delete_version(version_id=vid2)
+
+ # Verify it's deleted
+ versions_after = fresh_db.list_versions(d)
+ assert len(versions_after) == 2
+
+ # Make sure the deleted version is not in the list
+ version_ids = [v["id"] for v in versions_after]
+ assert vid2 not in version_ids
+ assert vid1 in version_ids
+ assert vid3 in version_ids
+
+
+def test_update_reminder_active(fresh_db):
+ """Test updating the active status of a reminder."""
+ from bouquin.reminders import Reminder, ReminderType
+
+ # Create a reminder object
+ reminder = Reminder(
+ id=None,
+ text="Test reminder",
+ reminder_type=ReminderType.ONCE,
+ time_str="14:30",
+ date_iso=date.today().isoformat(),
+ active=True,
+ )
+
+ # Save it
+ reminder_id = fresh_db.save_reminder(reminder)
+
+ # Verify it's active
+ reminders = fresh_db.get_all_reminders()
+ active_reminder = [r for r in reminders if r.id == reminder_id][0]
+ assert active_reminder.active is True
+
+ # Deactivate it
+ fresh_db.update_reminder_active(reminder_id, False)
+
+ # Verify it's inactive
+ reminders = fresh_db.get_all_reminders()
+ inactive_reminder = [r for r in reminders if r.id == reminder_id][0]
+ assert inactive_reminder.active is False
+
+ # Reactivate it
+ fresh_db.update_reminder_active(reminder_id, True)
+
+ # Verify it's active again
+ reminders = fresh_db.get_all_reminders()
+ reactivated_reminder = [r for r in reminders if r.id == reminder_id][0]
+ assert reactivated_reminder.active is True
diff --git a/tests/test_document_utils.py b/tests/test_document_utils.py
new file mode 100644
index 0000000..e1301df
--- /dev/null
+++ b/tests/test_document_utils.py
@@ -0,0 +1,289 @@
+import tempfile
+from pathlib import Path
+from unittest.mock import patch
+
+from PySide6.QtCore import QUrl
+from PySide6.QtGui import QDesktopServices
+from PySide6.QtWidgets import QMessageBox, QWidget
+
+
+def test_open_document_from_db_success(qtbot, app, fresh_db):
+ """Test successfully opening a document."""
+ # Import here to avoid circular import issues
+ from bouquin.document_utils import open_document_from_db
+
+ # Add a project and document
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test content for document")
+
+ try:
+ doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ # Mock QDesktopServices.openUrl
+ with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
+ # Call the function
+ success = open_document_from_db(
+ fresh_db, doc_id, doc_path.name, parent_widget=None
+ )
+
+ # Verify success
+ assert success is True
+
+ # Verify openUrl was called with a QUrl
+ assert mock_open.called
+ args = mock_open.call_args[0]
+ assert isinstance(args[0], QUrl)
+
+ # Verify the URL points to a local file
+ url_string = args[0].toString()
+ assert url_string.startswith("file://")
+ assert "bouquin_doc_" in url_string
+ assert doc_path.suffix in url_string
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_open_document_from_db_with_parent_widget(qtbot, app, fresh_db):
+ """Test opening a document with a parent widget provided."""
+ from bouquin.document_utils import open_document_from_db
+
+ # Create a parent widget
+ parent = QWidget()
+ qtbot.addWidget(parent)
+
+ # Add a project and document
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".pdf"))
+ doc_path.write_text("PDF content")
+
+ try:
+ doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
+ success = open_document_from_db(
+ fresh_db, doc_id, doc_path.name, parent_widget=parent
+ )
+
+ assert success is True
+ assert mock_open.called
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_open_document_from_db_nonexistent_document(qtbot, app, fresh_db):
+ """Test opening a non-existent document returns False."""
+ from bouquin.document_utils import open_document_from_db
+
+ # Try to open a document that doesn't exist
+ success = open_document_from_db(
+ fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=None
+ )
+
+ # Should return False
+ assert success is False
+
+
+def test_open_document_from_db_shows_error_with_parent(qtbot, app, fresh_db):
+ """Test that error dialog is shown when parent widget is provided."""
+ from bouquin.document_utils import open_document_from_db
+
+ parent = QWidget()
+ qtbot.addWidget(parent)
+
+ # Mock QMessageBox.warning
+ with patch.object(QMessageBox, "warning") as mock_warning:
+ success = open_document_from_db(
+ fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=parent
+ )
+
+ # Should return False and show warning
+ assert success is False
+ assert mock_warning.called
+
+ # Verify warning was shown with correct parent
+ call_args = mock_warning.call_args[0]
+ assert call_args[0] is parent
+
+
+def test_open_document_from_db_no_error_dialog_without_parent(qtbot, app, fresh_db):
+ """Test that no error dialog is shown when parent widget is None."""
+ from bouquin.document_utils import open_document_from_db
+
+ with patch.object(QMessageBox, "warning") as mock_warning:
+ success = open_document_from_db(
+ fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=None
+ )
+
+ # Should return False but NOT show warning
+ assert success is False
+ assert not mock_warning.called
+
+
+def test_open_document_from_db_preserves_file_extension(qtbot, app, fresh_db):
+ """Test that the temporary file has the correct extension."""
+ from bouquin.document_utils import open_document_from_db
+
+ # Test various file extensions
+ extensions = [".txt", ".pdf", ".docx", ".xlsx", ".jpg", ".png"]
+
+ for ext in extensions:
+ proj_id = fresh_db.add_project(f"Project for {ext}")
+ doc_path = Path(tempfile.mktemp(suffix=ext))
+ doc_path.write_text(f"content for {ext}")
+
+ try:
+ doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ with patch.object(
+ QDesktopServices, "openUrl", return_value=True
+ ) as mock_open:
+ open_document_from_db(fresh_db, doc_id, doc_path.name)
+
+ # Get the URL that was opened
+ url = mock_open.call_args[0][0]
+ url_string = url.toString()
+
+ # Verify the extension is preserved
+ assert ext in url_string, f"Extension {ext} not found in {url_string}"
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_open_document_from_db_file_without_extension(qtbot, app, fresh_db):
+ """Test opening a document without a file extension."""
+ from bouquin.document_utils import open_document_from_db
+
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp()) # No suffix
+ doc_path.write_text("content without extension")
+
+ try:
+ doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
+ success = open_document_from_db(fresh_db, doc_id, doc_path.name)
+
+ # Should still succeed
+ assert success is True
+ assert mock_open.called
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_open_document_from_db_desktop_services_failure(qtbot, app, fresh_db):
+ """Test handling when QDesktopServices.openUrl returns False."""
+ from bouquin.document_utils import open_document_from_db
+
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test content")
+
+ try:
+ doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ # Mock openUrl to return False (failure)
+ with patch.object(QDesktopServices, "openUrl", return_value=False):
+ success = open_document_from_db(fresh_db, doc_id, doc_path.name)
+
+ # Should return False
+ assert success is False
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_open_document_from_db_binary_content(qtbot, app, fresh_db):
+ """Test opening a document with binary content."""
+ from bouquin.document_utils import open_document_from_db
+
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".bin"))
+
+ # Write some binary data
+ binary_data = bytes([0, 1, 2, 3, 255, 254, 253])
+ doc_path.write_bytes(binary_data)
+
+ try:
+ doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
+ success = open_document_from_db(fresh_db, doc_id, doc_path.name)
+
+ assert success is True
+ assert mock_open.called
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_open_document_from_db_large_file(qtbot, app, fresh_db):
+ """Test opening a large document."""
+ from bouquin.document_utils import open_document_from_db
+
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".bin"))
+
+ # Create a 1MB file
+ large_data = b"x" * (1024 * 1024)
+ doc_path.write_bytes(large_data)
+
+ try:
+ doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
+ success = open_document_from_db(fresh_db, doc_id, doc_path.name)
+
+ assert success is True
+ assert mock_open.called
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_open_document_from_db_temp_file_prefix(qtbot, app, fresh_db):
+ """Test that temporary files have the correct prefix."""
+ from bouquin.document_utils import open_document_from_db
+
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test")
+
+ try:
+ doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
+ open_document_from_db(fresh_db, doc_id, doc_path.name)
+
+ url = mock_open.call_args[0][0]
+ url_path = url.toLocalFile()
+
+ # Verify the temp file has the bouquin_doc_ prefix
+ assert "bouquin_doc_" in url_path
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_open_document_from_db_multiple_calls(qtbot, app, fresh_db):
+ """Test opening the same document multiple times."""
+ from bouquin.document_utils import open_document_from_db
+
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test content")
+
+ try:
+ doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
+ # Open the same document 3 times
+ for _ in range(3):
+ success = open_document_from_db(fresh_db, doc_id, doc_path.name)
+ assert success is True
+
+ # Should have been called 3 times
+ assert mock_open.call_count == 3
+
+ # Each call should create a different temp file
+ call_urls = [call[0][0].toString() for call in mock_open.call_args_list]
+ # All URLs should be different (different temp files)
+ assert len(set(call_urls)) == 3
+ finally:
+ doc_path.unlink(missing_ok=True)
diff --git a/tests/test_documents.py b/tests/test_documents.py
new file mode 100644
index 0000000..0740b40
--- /dev/null
+++ b/tests/test_documents.py
@@ -0,0 +1,1060 @@
+import tempfile
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+from bouquin.db import DBConfig
+from bouquin.documents import DocumentsDialog, TodaysDocumentsWidget
+from PySide6.QtCore import Qt, QUrl
+from PySide6.QtGui import QDesktopServices
+from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox
+
+# =============================================================================
+# TodaysDocumentsWidget Tests
+# =============================================================================
+
+
+def test_todays_documents_widget_init(qtbot, app, fresh_db):
+ """Test TodaysDocumentsWidget initialization."""
+ date_iso = "2024-01-15"
+ widget = TodaysDocumentsWidget(fresh_db, date_iso)
+ qtbot.addWidget(widget)
+
+ assert widget._db is fresh_db
+ assert widget._current_date == date_iso
+ assert widget.toggle_btn is not None
+ assert widget.open_btn is not None
+ assert widget.list is not None
+ assert not widget.body.isVisible()
+
+
+def test_todays_documents_widget_reload_no_documents(qtbot, app, fresh_db):
+ """Test reload when there are no documents for today."""
+ date_iso = "2024-01-15"
+ widget = TodaysDocumentsWidget(fresh_db, date_iso)
+ qtbot.addWidget(widget)
+
+ # Should have one disabled item saying "no documents"
+ assert widget.list.count() == 1
+ item = widget.list.item(0)
+ assert not (item.flags() & Qt.ItemIsEnabled)
+
+
+def test_todays_documents_widget_reload_with_documents(qtbot, app, fresh_db):
+ """Test reload when there are documents for today."""
+ # Add a project
+ proj_id = fresh_db.add_project("Test Project")
+
+ # Add a document to the project
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test content")
+ try:
+ fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ # Mark document as accessed today
+ date_iso = "2024-01-15"
+ # The todays_documents method checks updated_at, so we need to ensure
+ # the document shows up in today's query
+
+ widget = TodaysDocumentsWidget(fresh_db, date_iso)
+ qtbot.addWidget(widget)
+
+ # At minimum, widget should be created without error
+ assert widget.list is not None
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_todays_documents_widget_set_current_date(qtbot, app, fresh_db):
+ """Test changing the current date."""
+ widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
+ qtbot.addWidget(widget)
+
+ # Change date
+ widget.set_current_date("2024-01-16")
+ assert widget._current_date == "2024-01-16"
+
+
+def test_todays_documents_widget_open_document(qtbot, app, fresh_db):
+ """Test opening a document."""
+ # Add a project and document
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test content")
+
+ try:
+ doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
+ qtbot.addWidget(widget)
+
+ # Mock QDesktopServices.openUrl
+ with patch.object(
+ QDesktopServices, "openUrl", return_value=True
+ ) as mock_open_url:
+ widget._open_document(doc_id, doc_path.name)
+
+ # Verify openUrl was called
+ assert mock_open_url.called
+ args = mock_open_url.call_args[0]
+ assert isinstance(args[0], QUrl)
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_todays_documents_widget_open_document_error(qtbot, app, fresh_db):
+ """Test opening a non-existent document shows error."""
+ widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
+ qtbot.addWidget(widget)
+
+ # Try to open non-existent document
+ with patch.object(QMessageBox, "warning") as mock_warning:
+ widget._open_document(99999, "nonexistent.txt")
+ assert mock_warning.called
+
+
+def test_todays_documents_widget_open_documents_dialog(qtbot, app, fresh_db):
+ """Test opening the full documents dialog."""
+ widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
+ qtbot.addWidget(widget)
+
+ # Mock DocumentsDialog
+ mock_dialog = MagicMock()
+ mock_dialog.exec.return_value = QDialog.Accepted
+
+ with patch("bouquin.documents.DocumentsDialog", return_value=mock_dialog):
+ widget._open_documents_dialog()
+ assert mock_dialog.exec.called
+
+
+# =============================================================================
+# DocumentsDialog Tests
+# =============================================================================
+
+
+def test_documents_dialog_init(qtbot, app, fresh_db):
+ """Test DocumentsDialog initialization."""
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ assert dialog._db is fresh_db
+ assert dialog.project_combo is not None
+ assert dialog.search_edit is not None
+ assert dialog.table is not None
+ assert dialog.table.columnCount() == 5
+
+
+def test_documents_dialog_init_with_initial_project(qtbot, app, fresh_db):
+ """Test DocumentsDialog with initial project ID."""
+ proj_id = fresh_db.add_project("Test Project")
+
+ dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ # Should select the specified project
+ # Verify project combo is populated
+ assert dialog.project_combo.count() > 0
+
+
+def test_documents_dialog_reload_projects(qtbot, app, fresh_db):
+ """Test reloading projects list."""
+ # Add some projects
+ fresh_db.add_project("Project 1")
+ fresh_db.add_project("Project 2")
+
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Check projects are loaded (including "All projects" option)
+ assert dialog.project_combo.count() >= 2
+
+
+def test_documents_dialog_reload_documents_no_project(qtbot, app, fresh_db):
+ """Test reloading documents when no project is selected."""
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ dialog._reload_documents()
+ # Should not crash
+
+
+def test_documents_dialog_reload_documents_with_project(qtbot, app, fresh_db):
+ """Test reloading documents for a specific project."""
+ # Add project and document
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test content")
+
+ try:
+ fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ # Table should have at least one row
+ assert (
+ dialog.table.rowCount() >= 0
+ ) # Might be 0 or 1 depending on how DB is set up
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_documents_dialog_add_document(qtbot, app, fresh_db):
+ """Test adding a document."""
+ proj_id = fresh_db.add_project("Test Project")
+
+ dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ # Create a temporary file to add
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test content")
+
+ try:
+ # Mock file dialog to return our test file
+ with patch.object(
+ QFileDialog, "getOpenFileNames", return_value=([str(doc_path)], "")
+ ):
+ dialog._on_add_clicked()
+
+ # Verify document was added (table should reload)
+ # The count might not change if the view isn't refreshed properly in test
+ # but the DB should have the document
+ docs = fresh_db.documents_for_project(proj_id)
+ assert len(docs) > 0
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_documents_dialog_add_document_no_project(qtbot, app, fresh_db):
+ """Test adding a document with no project selected shows warning."""
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Set to "All projects" (None)
+ dialog.project_combo.setCurrentIndex(0)
+
+ with patch.object(QMessageBox, "warning") as mock_warning:
+ dialog._on_add_clicked()
+ assert mock_warning.called
+
+
+def test_documents_dialog_add_document_file_error(qtbot, app, fresh_db):
+ """Test adding a document that doesn't exist shows warning."""
+ proj_id = fresh_db.add_project("Test Project")
+
+ dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ # Mock file dialog to return a non-existent file
+ with patch.object(
+ QFileDialog, "getOpenFileNames", return_value=(["/nonexistent/file.txt"], "")
+ ):
+ with patch.object(QMessageBox, "warning"):
+ dialog._on_add_clicked()
+ # Should show warning for file not found
+ # (this depends on add_document_from_path implementation)
+
+
+def test_documents_dialog_open_document(qtbot, app, fresh_db):
+ """Test opening a document from the dialog."""
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test content")
+
+ try:
+ doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ with patch.object(
+ QDesktopServices, "openUrl", return_value=True
+ ) as mock_open_url:
+ dialog._open_document(doc_id, doc_path.name)
+ assert mock_open_url.called
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_documents_dialog_delete_document(qtbot, app, fresh_db):
+ """Test deleting a document."""
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test content")
+
+ try:
+ fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ # Select the document in the table
+ if dialog.table.rowCount() > 0:
+ dialog.table.setCurrentCell(0, 0)
+
+ # Mock confirmation dialog
+ with patch.object(
+ QMessageBox, "question", return_value=QMessageBox.StandardButton.Yes
+ ):
+ dialog._on_delete_clicked()
+
+ # Document should be deleted
+ fresh_db.documents_for_project(proj_id)
+ # Depending on implementation, might be 0 or filtered
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_documents_dialog_delete_document_no_selection(qtbot, app, fresh_db):
+ """Test deleting with no selection does nothing."""
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Don't select anything
+ dialog.table.setCurrentCell(-1, -1)
+
+ # Should not crash
+ dialog._on_delete_clicked()
+
+
+def test_documents_dialog_delete_document_cancelled(qtbot, app, fresh_db):
+ """Test cancelling document deletion."""
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test content")
+
+ try:
+ fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ if dialog.table.rowCount() > 0:
+ dialog.table.setCurrentCell(0, 0)
+
+ # Mock confirmation dialog to return No
+ with patch.object(
+ QMessageBox, "question", return_value=QMessageBox.StandardButton.No
+ ):
+ dialog._on_delete_clicked()
+
+ # Document should still exist
+ docs = fresh_db.documents_for_project(proj_id)
+ assert len(docs) > 0
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_documents_dialog_edit_description(qtbot, app, fresh_db):
+ """Test editing a document's description inline."""
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test content")
+
+ try:
+ fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ if dialog.table.rowCount() > 0:
+ # Get the description cell
+ desc_item = dialog.table.item(0, dialog.DESC_COL)
+ if desc_item:
+ # Simulate editing
+ desc_item.setText("New description")
+ dialog._on_item_changed(desc_item)
+
+ # Verify description was updated in DB
+ docs = fresh_db.documents_for_project(proj_id)
+ if len(docs) > 0:
+ _, _, _, _, description, _, _ = docs[0]
+ assert description == "New description"
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_documents_dialog_edit_tags(qtbot, app, fresh_db):
+ """Test editing a document's tags inline."""
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test content")
+
+ try:
+ doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ if dialog.table.rowCount() > 0:
+ # Get the tags cell
+ tags_item = dialog.table.item(0, dialog.TAGS_COL)
+ if tags_item:
+ # Simulate editing tags
+ tags_item.setText("tag1, tag2, tag3")
+ dialog._on_item_changed(tags_item)
+
+ # Verify tags were updated in DB
+ tags = fresh_db.get_tags_for_document(doc_id)
+ tag_names = [name for (_, name, _) in tags]
+ assert "tag1" in tag_names
+ assert "tag2" in tag_names
+ assert "tag3" in tag_names
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_documents_dialog_tags_color_application(qtbot, app, fresh_db):
+ """Test that tag colors are applied to the tags cell."""
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test content")
+
+ try:
+ doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ # Add a tag with a color
+ fresh_db.add_tag("colored_tag", "#FF0000")
+ fresh_db.set_tags_for_document(doc_id, ["colored_tag"])
+
+ dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ if dialog.table.rowCount() > 0:
+ tags_item = dialog.table.item(0, dialog.TAGS_COL)
+ if tags_item:
+ # Check that background color was applied
+ bg_color = tags_item.background().color()
+ assert bg_color.isValid()
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_documents_dialog_search_functionality(qtbot, app, fresh_db):
+ """Test search functionality across all projects."""
+ # Add multiple projects with documents
+ proj1 = fresh_db.add_project("Project 1")
+ proj2 = fresh_db.add_project("Project 2")
+
+ doc1_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc1_path.write_text("apple content")
+ doc2_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc2_path.write_text("banana content")
+
+ try:
+ fresh_db.add_document_from_path(proj1, str(doc1_path))
+ fresh_db.add_document_from_path(proj2, str(doc2_path))
+
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Perform search
+ dialog.search_edit.setText("apple")
+ dialog._on_search_text_changed("apple")
+
+ # Should show search results
+ # Implementation depends on search_documents query
+ finally:
+ doc1_path.unlink(missing_ok=True)
+ doc2_path.unlink(missing_ok=True)
+
+
+def test_documents_dialog_manage_projects_button(qtbot, app, fresh_db):
+ """Test clicking manage projects button."""
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Mock TimeCodeManagerDialog
+ mock_mgr_dialog = MagicMock()
+ mock_mgr_dialog.exec.return_value = QDialog.Accepted
+
+ with patch("bouquin.documents.TimeCodeManagerDialog", return_value=mock_mgr_dialog):
+ dialog._manage_projects()
+ assert mock_mgr_dialog.exec.called
+
+
+def test_documents_dialog_format_size(qtbot, app, fresh_db):
+ """Test file size formatting."""
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Test various sizes
+ assert "B" in dialog._format_size(500)
+ assert "KB" in dialog._format_size(2048)
+ assert "MB" in dialog._format_size(2 * 1024 * 1024)
+ assert "GB" in dialog._format_size(2 * 1024 * 1024 * 1024)
+
+
+def test_documents_dialog_current_project_all(qtbot, app, fresh_db):
+ """Test _current_project returns None for 'All Projects'."""
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Set to first item (All Projects)
+ dialog.project_combo.setCurrentIndex(0)
+
+ proj_id = dialog._current_project()
+ assert proj_id is None
+
+
+def test_documents_dialog_current_project_specific(qtbot, app, fresh_db):
+ """Test _current_project returns correct project ID."""
+ proj_id = fresh_db.add_project("Test Project")
+
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Find and select the test project
+ for i in range(dialog.project_combo.count()):
+ if dialog.project_combo.itemData(i) == proj_id:
+ dialog.project_combo.setCurrentIndex(i)
+ break
+
+ current_proj = dialog._current_project()
+ if current_proj is not None:
+ assert current_proj == proj_id
+
+
+def test_documents_dialog_table_double_click_opens_document(qtbot, app, fresh_db):
+ """Test double-clicking a document opens it."""
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test content")
+
+ try:
+ fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ if dialog.table.rowCount() > 0:
+ with patch.object(QDesktopServices, "openUrl", return_value=True):
+ # Simulate double-click
+ dialog._on_open_clicked()
+
+ # Should attempt to open if a row is selected
+ # (behavior depends on whether table selection is set up properly)
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_documents_dialog_selected_doc_meta_no_selection(qtbot, app, fresh_db):
+ """Test _selected_doc_meta with no selection."""
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ doc_id, file_name = dialog._selected_doc_meta()
+ assert doc_id is None
+ assert file_name is None
+
+
+def test_documents_dialog_selected_doc_meta_with_selection(qtbot, app, fresh_db):
+ """Test _selected_doc_meta with a valid selection."""
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test content")
+
+ try:
+ fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ if dialog.table.rowCount() > 0:
+ dialog.table.setCurrentCell(0, 0)
+
+ sel_doc_id, sel_file_name = dialog._selected_doc_meta()
+ # May or may not be None depending on how table is populated
+ # At minimum, should not crash
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_documents_dialog_item_changed_ignores_during_reload(qtbot, app, fresh_db):
+ """Test _on_item_changed is ignored during reload."""
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Set reloading flag
+ dialog._reloading_docs = True
+
+ # Create a mock item
+ from PySide6.QtWidgets import QTableWidgetItem
+
+ item = QTableWidgetItem("test")
+
+ # Should not crash or do anything
+ dialog._on_item_changed(item)
+
+ dialog._reloading_docs = False
+
+
+def test_documents_dialog_search_clears_properly(qtbot, app, fresh_db):
+ """Test clearing search box resets to project view."""
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Enter search text
+ dialog.search_edit.setText("test")
+ dialog._on_search_text_changed("test")
+
+ # Clear search
+ dialog.search_edit.clear()
+ dialog._on_search_text_changed("")
+
+ # Should reset to normal project view
+ assert dialog._search_text == ""
+
+
+def test_todays_documents_widget_reload_with_project_names(qtbot, app, fresh_db):
+ """Test reload when documents have project names."""
+ # Add a project and document
+ proj_id = fresh_db.add_project("My Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test content")
+
+ try:
+ doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ # Mock todays_documents to return a document with project name
+ with patch.object(fresh_db, "todays_documents") as mock_today:
+ mock_today.return_value = [(doc_id, doc_path.name, "My Project")]
+
+ widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
+ qtbot.addWidget(widget)
+ widget.reload()
+
+ # Should have one item with project name in label
+ assert widget.list.count() == 1
+ item = widget.list.item(0)
+ assert "My Project" in item.text()
+ assert doc_path.name in item.text()
+
+ # Check data was stored
+ data = item.data(Qt.ItemDataRole.UserRole)
+ assert isinstance(data, dict)
+ assert data["doc_id"] == doc_id
+ assert data["file_name"] == doc_path.name
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_todays_documents_widget_on_toggle_expand(qtbot, app, fresh_db):
+ """Test toggle behavior when expanding."""
+ widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
+ qtbot.addWidget(widget)
+ widget.show()
+ qtbot.waitExposed(widget)
+
+ # Initially collapsed
+ assert not widget.body.isVisible()
+
+ # Call _on_toggle directly
+ widget._on_toggle(True)
+
+ # Should be expanded
+ assert widget.body.isVisible()
+ assert widget.toggle_btn.arrowType() == Qt.DownArrow
+
+
+def test_todays_documents_widget_on_toggle_collapse(qtbot, app, fresh_db):
+ """Test toggle behavior when collapsing."""
+ widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
+ qtbot.addWidget(widget)
+ widget.show()
+ qtbot.waitExposed(widget)
+
+ # Expand first
+ widget._on_toggle(True)
+ assert widget.body.isVisible()
+
+ # Now collapse
+ widget._on_toggle(False)
+
+ # Should be collapsed
+ assert not widget.body.isVisible()
+ assert widget.toggle_btn.arrowType() == Qt.RightArrow
+
+
+def test_todays_documents_widget_set_current_date_triggers_reload(qtbot, app, fresh_db):
+ """Test that set_current_date triggers a reload."""
+ widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
+ qtbot.addWidget(widget)
+
+ # Mock reload to verify it's called
+ with patch.object(widget, "reload") as mock_reload:
+ widget.set_current_date("2024-01-16")
+
+ assert widget._current_date == "2024-01-16"
+ assert mock_reload.called
+
+
+def test_todays_documents_widget_double_click_with_invalid_data(qtbot, app, fresh_db):
+ """Test double-clicking item with invalid data."""
+ widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
+ qtbot.addWidget(widget)
+
+ # Add item with invalid data
+ from PySide6.QtWidgets import QListWidgetItem
+
+ item = QListWidgetItem("Test")
+ item.setData(Qt.ItemDataRole.UserRole, "not a dict")
+ widget.list.addItem(item)
+
+ # Double-click should not crash
+ widget._open_selected_document(item)
+
+
+def test_todays_documents_widget_double_click_with_missing_doc_id(qtbot, app, fresh_db):
+ """Test double-clicking item with missing doc_id."""
+ widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
+ qtbot.addWidget(widget)
+
+ from PySide6.QtWidgets import QListWidgetItem
+
+ item = QListWidgetItem("Test")
+ item.setData(Qt.ItemDataRole.UserRole, {"file_name": "test.txt"})
+ widget.list.addItem(item)
+
+ # Should return early without crashing
+ widget._open_selected_document(item)
+
+
+def test_todays_documents_widget_double_click_with_missing_filename(
+ qtbot, app, fresh_db
+):
+ """Test double-clicking item with missing file_name."""
+ widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
+ qtbot.addWidget(widget)
+
+ from PySide6.QtWidgets import QListWidgetItem
+
+ item = QListWidgetItem("Test")
+ item.setData(Qt.ItemDataRole.UserRole, {"doc_id": 1})
+ widget.list.addItem(item)
+
+ # Should return early without crashing
+ widget._open_selected_document(item)
+
+
+def test_documents_dialog_reload_calls_on_init(qtbot, app, fresh_db):
+ """Test that _reload_documents is called on initialization."""
+ # Add a project so the combo will have items
+ fresh_db.add_project("Test Project")
+
+ # This covers line 300
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Should have projects loaded (covers _reload_projects line 300-301)
+ assert dialog.project_combo.count() > 0
+
+
+def test_documents_dialog_tags_column_hidden_when_disabled(qtbot, app, tmp_path):
+ """Test that tags column is hidden when tags are disabled in config."""
+ # Create a config with tags disabled
+ db_path = tmp_path / "test.db"
+ cfg = DBConfig(
+ path=db_path,
+ key="test-key",
+ idle_minutes=0,
+ theme="light",
+ move_todos=True,
+ tags=False, # Tags disabled
+ time_log=True,
+ reminders=True,
+ locale="en",
+ font_size=11,
+ )
+
+ from bouquin.db import DBManager
+
+ db = DBManager(cfg)
+ db.connect()
+
+ try:
+ # Add project and document
+ proj_id = db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test")
+
+ try:
+ db.add_document_from_path(proj_id, str(doc_path))
+
+ # Patch load_db_config to return our custom config
+ with patch("bouquin.documents.load_db_config", return_value=cfg):
+ dialog = DocumentsDialog(db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ # Tags column should be hidden (covers lines 400-401)
+ # The column is hidden inside _reload_documents when there are rows
+ assert dialog.table.isColumnHidden(dialog.TAGS_COL)
+ finally:
+ doc_path.unlink(missing_ok=True)
+ finally:
+ db.close()
+
+
+def test_documents_dialog_project_changed_triggers_reload(qtbot, app, fresh_db):
+ """Test that changing project triggers document reload."""
+ fresh_db.add_project("Project 1")
+ fresh_db.add_project("Project 2")
+
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Mock _reload_documents
+ with patch.object(dialog, "_reload_documents") as mock_reload:
+ # Change project
+ dialog._on_project_changed(1)
+
+ # Should have triggered reload (covers line 421-424)
+ assert mock_reload.called
+
+
+def test_documents_dialog_add_with_cancelled_dialog(qtbot, app, fresh_db):
+ """Test adding document when file dialog is cancelled."""
+ proj_id = fresh_db.add_project("Test Project")
+ dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ # Mock file dialog to return empty (cancelled)
+ with patch.object(QFileDialog, "getOpenFileNames", return_value=([], "")):
+ initial_count = dialog.table.rowCount()
+ dialog._on_add_clicked()
+
+ # No documents should be added (covers line 442)
+ assert dialog.table.rowCount() == initial_count
+
+
+def test_documents_dialog_delete_with_cancelled_confirmation(qtbot, app, fresh_db):
+ """Test deleting document when user cancels confirmation."""
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test")
+
+ try:
+ fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ if dialog.table.rowCount() > 0:
+ dialog.table.setCurrentCell(0, 0)
+
+ # Mock to return No
+ with patch.object(
+ QMessageBox, "question", return_value=QMessageBox.StandardButton.No
+ ):
+ dialog._on_delete_clicked()
+
+ # Document should still exist (covers line 486)
+ docs = fresh_db.documents_for_project(proj_id)
+ assert len(docs) > 0
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_documents_dialog_edit_tags_with_empty_result(qtbot, app, fresh_db):
+ """Test editing tags when result is empty after setting."""
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test")
+
+ try:
+ fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ if dialog.table.rowCount() > 0:
+ tags_item = dialog.table.item(0, dialog.TAGS_COL)
+ if tags_item:
+ # Set empty tags
+ tags_item.setText("")
+ dialog._on_item_changed(tags_item)
+
+ # Background should be cleared (covers lines 523-524)
+ # Just verify no crash
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_documents_dialog_on_open_with_no_selection(qtbot, app, fresh_db):
+ """Test _on_open_clicked with no selection."""
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Don't select anything
+ dialog.table.setCurrentCell(-1, -1)
+
+ # Should not crash (early return)
+ dialog._on_open_clicked()
+
+
+def test_documents_dialog_search_with_results(qtbot, app, fresh_db):
+ """Test search functionality with actual results."""
+ proj_id = fresh_db.add_project("Test Project")
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("searchable content")
+
+ try:
+ doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ # Update document to have searchable description
+ fresh_db.update_document_description(doc_id, "searchable description")
+
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Mock search_documents to return results
+ with patch.object(fresh_db, "search_documents") as mock_search:
+ mock_search.return_value = [
+ (
+ doc_id,
+ proj_id,
+ "Test Project",
+ doc_path.name,
+ "searchable description",
+ 100,
+ "2024-01-15",
+ )
+ ]
+
+ # Perform search
+ dialog.search_edit.setText("searchable")
+ dialog._on_search_text_changed("searchable")
+
+ # Should show results
+ assert dialog.table.rowCount() > 0
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_documents_dialog_on_item_changed_invalid_item(qtbot, app, fresh_db):
+ """Test _on_item_changed with None item."""
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Call with None
+ dialog._on_item_changed(None)
+
+ # Should not crash
+
+
+def test_documents_dialog_on_item_changed_no_file_item(qtbot, app, fresh_db):
+ """Test _on_item_changed when file item is None."""
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Manually add a row without proper file item
+ dialog.table.setRowCount(1)
+ from PySide6.QtWidgets import QTableWidgetItem
+
+ desc_item = QTableWidgetItem("Test")
+ dialog.table.setItem(0, dialog.DESC_COL, desc_item)
+
+ # Call on_item_changed
+ dialog._on_item_changed(desc_item)
+
+ # Should return early without crashing
+
+
+def test_documents_dialog_format_size_edge_cases(qtbot, app, fresh_db):
+ """Test _format_size with edge cases."""
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Test 0 bytes
+ assert dialog._format_size(0) == "0 B"
+
+ # Test exact KB boundary
+ assert "1.0 KB" in dialog._format_size(1024)
+
+ # Test exact MB boundary
+ assert "1.0 MB" in dialog._format_size(1024 * 1024)
+
+ # Test exact GB boundary
+ assert "1.0 GB" in dialog._format_size(1024 * 1024 * 1024)
+
+
+def test_documents_dialog_selected_doc_meta_no_file_item(qtbot, app, fresh_db):
+ """Test _selected_doc_meta when file item is None."""
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Add a row without file item
+ dialog.table.setRowCount(1)
+ dialog.table.setCurrentCell(0, 0)
+
+ doc_id, file_name = dialog._selected_doc_meta()
+
+ # Should return None, None
+ assert doc_id is None
+ assert file_name is None
+
+
+def test_documents_dialog_initial_project_selection(qtbot, app, fresh_db):
+ """Test dialog with initial_project_id selects correct project."""
+ proj_id = fresh_db.add_project("Selected Project")
+
+ # Add a document to ensure something shows
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text("test")
+
+ try:
+ fresh_db.add_document_from_path(proj_id, str(doc_path))
+
+ dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
+ qtbot.addWidget(dialog)
+
+ # Should have selected the project
+ current_proj = dialog._current_project()
+ assert current_proj == proj_id
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+
+def test_todays_documents_widget_reload_multiple_documents(qtbot, app, fresh_db):
+ """Test reload with multiple documents."""
+ proj_id = fresh_db.add_project("Project")
+
+ # Add multiple documents
+ doc_ids = []
+ for i in range(3):
+ doc_path = Path(tempfile.mktemp(suffix=".txt"))
+ doc_path.write_text(f"content {i}")
+ try:
+ doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
+ doc_ids.append((doc_id, doc_path.name))
+ finally:
+ doc_path.unlink(missing_ok=True)
+
+ # Mock todays_documents
+ with patch.object(fresh_db, "todays_documents") as mock_today:
+ mock_today.return_value = [
+ (doc_id, name, "Project") for doc_id, name in doc_ids
+ ]
+
+ widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
+ qtbot.addWidget(widget)
+ widget.reload()
+
+ # Should have 3 items
+ assert widget.list.count() == 3
+
+
+def test_documents_dialog_manage_projects_button_clicked(qtbot, app, fresh_db):
+ """Test clicking manage projects button."""
+ dialog = DocumentsDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Mock TimeCodeManagerDialog
+ mock_mgr_dialog = MagicMock()
+ mock_mgr_dialog.exec.return_value = QDialog.Accepted
+
+ with patch("bouquin.documents.TimeCodeManagerDialog", return_value=mock_mgr_dialog):
+ dialog._manage_projects()
+
+ # Should have opened the manager dialog
+ assert mock_mgr_dialog.exec.called
diff --git a/tests/test_find_bar.py b/tests/test_find_bar.py
index c0ab938..de67c7e 100644
--- a/tests/test_find_bar.py
+++ b/tests/test_find_bar.py
@@ -1,10 +1,9 @@
import pytest
-
+from bouquin.find_bar import FindBar
+from bouquin.markdown_editor import MarkdownEditor
+from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtGui import QTextCursor
from PySide6.QtWidgets import QTextEdit, QWidget
-from bouquin.markdown_editor import MarkdownEditor
-from bouquin.theme import ThemeManager, ThemeConfig, Theme
-from bouquin.find_bar import FindBar
@pytest.fixture
diff --git a/tests/test_history_dialog.py b/tests/test_history_dialog.py
index b1cef62..98ab9c8 100644
--- a/tests/test_history_dialog.py
+++ b/tests/test_history_dialog.py
@@ -1,7 +1,6 @@
-from PySide6.QtWidgets import QWidget, QMessageBox, QApplication
-from PySide6.QtCore import Qt, QTimer
-
from bouquin.history_dialog import HistoryDialog
+from PySide6.QtCore import Qt, QTimer
+from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
def test_history_dialog_lists_and_revert(qtbot, fresh_db):
@@ -167,3 +166,145 @@ def test_revert_exception_shows_message(qtbot, fresh_db, monkeypatch):
# Should show the critical box, which our timer will accept; _revert returns.
dlg._revert()
+
+
+def test_delete_version_from_history(qtbot, fresh_db):
+ """Test deleting a version through the history dialog."""
+ d = "2001-01-01"
+
+ # Create multiple versions
+ vid1, _ = fresh_db.save_new_version(d, "v1", "first")
+ vid2, _ = fresh_db.save_new_version(d, "v2", "second")
+ vid3, _ = fresh_db.save_new_version(d, "v3", "third")
+
+ w = QWidget()
+ dlg = HistoryDialog(fresh_db, d, parent=w)
+ qtbot.addWidget(dlg)
+ dlg.show()
+
+ # Verify we have 3 versions
+ assert dlg.list.count() == 3
+
+ # Select the first version (oldest, not current)
+ dlg.list.setCurrentRow(2) # Last row is oldest version
+
+ # Call _delete
+ dlg._delete()
+
+ # Verify the version was deleted
+ assert dlg.list.count() == 2
+
+ # Verify from DB
+ versions = fresh_db.list_versions(d)
+ assert len(versions) == 2
+
+
+def test_delete_current_version_returns_early(qtbot, fresh_db):
+ """Test that deleting the current version returns early without deleting."""
+ d = "2001-01-02"
+
+ # Create versions
+ vid1, _ = fresh_db.save_new_version(d, "v1", "first")
+ vid2, _ = fresh_db.save_new_version(d, "v2", "second")
+
+ w = QWidget()
+ dlg = HistoryDialog(fresh_db, d, parent=w)
+ qtbot.addWidget(dlg)
+ dlg.show()
+
+ # Find and select the current version
+ for i in range(dlg.list.count()):
+ item = dlg.list.item(i)
+ if item.data(Qt.UserRole) == dlg._current_id:
+ dlg.list.setCurrentItem(item)
+ break
+
+ # Try to delete - should return early
+ dlg._delete()
+
+ # Verify nothing was deleted
+ versions = fresh_db.list_versions(d)
+ assert len(versions) == 2
+
+
+def test_delete_version_with_error(qtbot, fresh_db, monkeypatch):
+ """Test that delete version error shows a message box."""
+ d = "2001-01-03"
+
+ # Create versions
+ vid1, _ = fresh_db.save_new_version(d, "v1", "first")
+ vid2, _ = fresh_db.save_new_version(d, "v2", "second")
+
+ w = QWidget()
+ dlg = HistoryDialog(fresh_db, d, parent=w)
+ qtbot.addWidget(dlg)
+ dlg.show()
+
+ # Select a non-current version
+ for i in range(dlg.list.count()):
+ item = dlg.list.item(i)
+ if item.data(Qt.UserRole) != dlg._current_id:
+ dlg.list.setCurrentItem(item)
+ break
+
+ # Make delete_version raise an error
+ def boom(*args, **kwargs):
+ raise RuntimeError("Delete failed")
+
+ monkeypatch.setattr(dlg._db, "delete_version", boom)
+
+ # Set up auto-closer for message box
+ def make_closer(max_tries=50, interval_ms=10):
+ tries = {"n": 0}
+
+ def closer():
+ tries["n"] += 1
+ w = QApplication.activeModalWidget()
+ if isinstance(w, QMessageBox):
+ ok = w.button(QMessageBox.Ok)
+ if ok is not None:
+ ok.click()
+ else:
+ w.accept()
+ elif tries["n"] < max_tries:
+ QTimer.singleShot(interval_ms, closer)
+
+ return closer
+
+ QTimer.singleShot(0, make_closer())
+
+ # Call delete - should show error message
+ dlg._delete()
+
+
+def test_delete_multiple_versions(qtbot, fresh_db):
+ """Test deleting multiple versions at once."""
+ d = "2001-01-04"
+
+ # Create multiple versions
+ vid1, _ = fresh_db.save_new_version(d, "v1", "first")
+ vid2, _ = fresh_db.save_new_version(d, "v2", "second")
+ vid3, _ = fresh_db.save_new_version(d, "v3", "third")
+ vid4, _ = fresh_db.save_new_version(d, "v4", "fourth")
+
+ w = QWidget()
+ dlg = HistoryDialog(fresh_db, d, parent=w)
+ qtbot.addWidget(dlg)
+ dlg.show()
+
+ # Select multiple non-current items
+ selected_count = 0
+ for i in range(dlg.list.count()):
+ item = dlg.list.item(i)
+ if item.data(Qt.UserRole) != dlg._current_id:
+ item.setSelected(True)
+ selected_count += 1
+ if selected_count >= 2: # Select 2 items
+ break
+
+ # Delete them
+ dlg._delete()
+
+ # Verify versions were deleted (should have current + 1 remaining)
+ versions = fresh_db.list_versions(d)
+ assert len(versions) == 2 # Current + 1 that wasn't deleted
diff --git a/tests/test_invoices.py b/tests/test_invoices.py
new file mode 100644
index 0000000..89ef202
--- /dev/null
+++ b/tests/test_invoices.py
@@ -0,0 +1,1346 @@
+from datetime import date, timedelta
+
+import pytest
+from bouquin.invoices import (
+ _INVOICE_REMINDER_TIME,
+ InvoiceDetailMode,
+ InvoiceDialog,
+ InvoiceLineItem,
+ InvoicesDialog,
+ _invoice_due_reminder_text,
+)
+from bouquin.reminders import Reminder, ReminderType
+from PySide6.QtCore import QDate, Qt
+from PySide6.QtWidgets import QMessageBox
+
+# ============================================================================
+# Tests for InvoiceDetailMode enum
+# ============================================================================
+
+
+def test_invoice_detail_mode_enum_values(app):
+ """Test InvoiceDetailMode enum has expected values."""
+ assert InvoiceDetailMode.DETAILED == "detailed"
+ assert InvoiceDetailMode.SUMMARY == "summary"
+
+
+def test_invoice_detail_mode_is_string(app):
+ """Test InvoiceDetailMode enum inherits from str."""
+ assert isinstance(InvoiceDetailMode.DETAILED, str)
+ assert isinstance(InvoiceDetailMode.SUMMARY, str)
+
+
+# ============================================================================
+# Tests for InvoiceLineItem dataclass
+# ============================================================================
+
+
+def test_invoice_line_item_creation(app):
+ """Test creating an InvoiceLineItem instance."""
+ item = InvoiceLineItem(
+ description="Development work",
+ hours=5.5,
+ rate_cents=10000,
+ amount_cents=55000,
+ )
+
+ assert item.description == "Development work"
+ assert item.hours == 5.5
+ assert item.rate_cents == 10000
+ assert item.amount_cents == 55000
+
+
+def test_invoice_line_item_with_zero_values(app):
+ """Test InvoiceLineItem with zero values."""
+ item = InvoiceLineItem(
+ description="",
+ hours=0.0,
+ rate_cents=0,
+ amount_cents=0,
+ )
+
+ assert item.description == ""
+ assert item.hours == 0.0
+ assert item.rate_cents == 0
+ assert item.amount_cents == 0
+
+
+# ============================================================================
+# Tests for _invoice_due_reminder_text helper function
+# ============================================================================
+
+
+def test_invoice_due_reminder_text_normal(app):
+ """Test reminder text generation with normal inputs."""
+ result = _invoice_due_reminder_text("Project Alpha", "INV-001")
+ assert result == "Invoice INV-001 for Project Alpha is due"
+
+
+def test_invoice_due_reminder_text_with_whitespace(app):
+ """Test reminder text strips whitespace from inputs."""
+ result = _invoice_due_reminder_text(" Project Beta ", " INV-002 ")
+ assert result == "Invoice INV-002 for Project Beta is due"
+
+
+def test_invoice_due_reminder_text_empty_project(app):
+ """Test reminder text with empty project name."""
+ result = _invoice_due_reminder_text("", "INV-003")
+ assert result == "Invoice INV-003 for (no project) is due"
+
+
+def test_invoice_due_reminder_text_empty_invoice_number(app):
+ """Test reminder text with empty invoice number."""
+ result = _invoice_due_reminder_text("Project Gamma", "")
+ assert result == "Invoice ? for Project Gamma is due"
+
+
+def test_invoice_due_reminder_text_both_empty(app):
+ """Test reminder text with both inputs empty."""
+ result = _invoice_due_reminder_text("", "")
+ assert result == "Invoice ? for (no project) is due"
+
+
+# ============================================================================
+# Tests for InvoiceDialog
+# ============================================================================
+
+
+@pytest.fixture
+def invoice_dialog_setup(qtbot, fresh_db):
+ """Set up a project with time logs for InvoiceDialog testing."""
+ # Create a project
+ proj_id = fresh_db.add_project("Test Project")
+
+ # Create an activity
+ act_id = fresh_db.add_activity("Development")
+
+ # Set billing info
+ fresh_db.upsert_project_billing(
+ proj_id,
+ hourly_rate_cents=15000, # $150/hr
+ currency="USD",
+ tax_label="VAT",
+ tax_rate_percent=20.0,
+ client_name="John Doe",
+ client_company="Acme Corp",
+ client_address="123 Main St",
+ client_email="john@acme.com",
+ )
+
+ # Create some time logs
+ today = date.today()
+ start_date = (today - timedelta(days=7)).isoformat()
+ end_date = today.isoformat()
+
+ # Add time logs for testing (2.5 hours = 150 minutes)
+ for i in range(3):
+ log_date = (today - timedelta(days=i)).isoformat()
+ fresh_db.add_time_log(
+ log_date,
+ proj_id,
+ act_id,
+ 150, # 2.5 hours in minutes
+ f"Note {i}",
+ )
+
+ time_rows = fresh_db.time_logs_for_range(proj_id, start_date, end_date)
+
+ return {
+ "db": fresh_db,
+ "proj_id": proj_id,
+ "act_id": act_id,
+ "start_date": start_date,
+ "end_date": end_date,
+ "time_rows": time_rows,
+ }
+
+
+def test_invoice_dialog_init(qtbot, invoice_dialog_setup):
+ """Test InvoiceDialog initialization."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ assert dialog._db is setup["db"]
+ assert dialog._project_id == setup["proj_id"]
+ assert dialog._start == setup["start_date"]
+ assert dialog._end == setup["end_date"]
+ assert len(dialog._time_rows) == 3
+
+
+def test_invoice_dialog_init_without_time_rows(qtbot, invoice_dialog_setup):
+ """Test InvoiceDialog initialization without explicit time_rows."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ )
+ qtbot.addWidget(dialog)
+
+ # Should fetch time rows from DB
+ assert len(dialog._time_rows) == 3
+
+
+def test_invoice_dialog_loads_billing_defaults(qtbot, invoice_dialog_setup):
+ """Test that InvoiceDialog loads billing defaults from project."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ assert dialog.currency_edit.text() == "USD"
+ assert dialog.rate_spin.value() == 150.0
+ assert dialog.client_name_edit.text() == "John Doe"
+ assert dialog.client_company_combo.currentText() == "Acme Corp"
+
+
+def test_invoice_dialog_no_billing_defaults(qtbot, fresh_db):
+ """Test InvoiceDialog with project that has no billing info."""
+ proj_id = fresh_db.add_project("Test Project No Billing")
+ today = date.today()
+ start = (today - timedelta(days=1)).isoformat()
+ end = today.isoformat()
+
+ dialog = InvoiceDialog(fresh_db, proj_id, start, end)
+ qtbot.addWidget(dialog)
+
+ # Should use defaults
+ assert dialog.currency_edit.text() == "AUD"
+ assert dialog.rate_spin.value() == 0.0
+ assert dialog.client_name_edit.text() == ""
+
+
+def test_invoice_dialog_project_name(qtbot, invoice_dialog_setup):
+ """Test _project_name method."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ project_name = dialog._project_name()
+ assert project_name == "Test Project"
+
+
+def test_invoice_dialog_suggest_invoice_number(qtbot, invoice_dialog_setup):
+ """Test _suggest_invoice_number method."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ invoice_number = dialog._suggest_invoice_number()
+ # Should be in format YYYY-001 for first invoice (3 digits)
+ current_year = date.today().year
+ assert invoice_number.startswith(str(current_year))
+ assert invoice_number.endswith("-001")
+
+
+def test_invoice_dialog_suggest_invoice_number_increments(qtbot, invoice_dialog_setup):
+ """Test that invoice number suggestions increment."""
+ setup = invoice_dialog_setup
+
+ # Create an invoice first
+ dialog1 = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog1)
+
+ # Save an invoice to increment the counter
+ invoice_number_1 = dialog1._suggest_invoice_number()
+ setup["db"].create_invoice(
+ project_id=setup["proj_id"],
+ invoice_number=invoice_number_1,
+ issue_date=date.today().isoformat(),
+ due_date=(date.today() + timedelta(days=14)).isoformat(),
+ currency="USD",
+ tax_label=None,
+ tax_rate_percent=None,
+ detail_mode=InvoiceDetailMode.DETAILED,
+ line_items=[],
+ time_log_ids=[],
+ )
+
+ # Create another dialog and check the number increments
+ dialog2 = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog2)
+
+ invoice_number_2 = dialog2._suggest_invoice_number()
+ current_year = date.today().year
+ assert invoice_number_2 == f"{current_year}-002"
+
+
+def test_invoice_dialog_populate_detailed_rows(qtbot, invoice_dialog_setup):
+ """Test _populate_detailed_rows method."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ dialog._populate_detailed_rows(15000) # $150/hr in cents
+
+ # Check that table has rows
+ assert dialog.table.rowCount() == 3
+
+ # Check that hours are displayed (COL_HOURS uses cellWidget, not item)
+ for row in range(3):
+ hours_widget = dialog.table.cellWidget(row, dialog.COL_HOURS)
+ assert hours_widget is not None
+ assert hours_widget.value() == 2.5
+
+
+def test_invoice_dialog_total_hours_from_table(qtbot, invoice_dialog_setup):
+ """Test _total_hours_from_table method."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ dialog._populate_detailed_rows(15000)
+
+ total_hours = dialog._total_hours_from_table()
+ # 3 rows * 2.5 hours = 7.5 hours
+ assert total_hours == 7.5
+
+
+def test_invoice_dialog_detail_line_items(qtbot, invoice_dialog_setup):
+ """Test _detail_line_items method."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ dialog.rate_spin.setValue(150.0)
+ dialog._populate_detailed_rows(15000)
+
+ line_items = dialog._detail_line_items()
+ assert len(line_items) == 3
+
+ for item in line_items:
+ assert isinstance(item, InvoiceLineItem)
+ assert item.hours == 2.5
+ assert item.rate_cents == 15000
+ assert item.amount_cents == 37500 # 2.5 * 15000
+
+
+def test_invoice_dialog_summary_line_items(qtbot, invoice_dialog_setup):
+ """Test _summary_line_items method."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ dialog.rate_spin.setValue(150.0)
+ dialog._populate_detailed_rows(15000)
+
+ line_items = dialog._summary_line_items()
+ assert len(line_items) == 1 # Summary should have one line
+
+ item = line_items[0]
+ assert isinstance(item, InvoiceLineItem)
+ # The description comes from summary_desc_edit which has a localized default
+ # Just check it's not empty
+ assert len(item.description) > 0
+ assert item.hours == 7.5 # Total of 3 * 2.5
+ assert item.rate_cents == 15000
+ assert item.amount_cents == 112500 # 7.5 * 15000
+
+
+def test_invoice_dialog_recalc_amounts(qtbot, invoice_dialog_setup):
+ """Test _recalc_amounts method."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ dialog._populate_detailed_rows(15000)
+ dialog.rate_spin.setValue(200.0) # Change rate to $200/hr
+
+ dialog._recalc_amounts()
+
+ # Check that amounts were recalculated
+ for row in range(3):
+ amount_item = dialog.table.item(row, dialog.COL_AMOUNT)
+ assert amount_item is not None
+ # 2.5 hours * $200 = $500
+ assert amount_item.text() == "500.00"
+
+
+def test_invoice_dialog_recalc_totals(qtbot, invoice_dialog_setup):
+ """Test _recalc_totals method."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ dialog.rate_spin.setValue(100.0)
+ dialog._populate_detailed_rows(10000)
+
+ # Enable tax
+ dialog.tax_checkbox.setChecked(True)
+ dialog.tax_rate_spin.setValue(10.0)
+
+ dialog._recalc_totals()
+
+ # 7.5 hours * $100 = $750
+ # Tax: $750 * 10% = $75
+ # Total: $750 + $75 = $825
+ assert "750.00" in dialog.subtotal_label.text()
+ assert "75.00" in dialog.tax_label_total.text()
+ assert "825.00" in dialog.total_label.text()
+
+
+def test_invoice_dialog_on_tax_toggled(qtbot, invoice_dialog_setup):
+ """Test _on_tax_toggled method."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+ dialog.show()
+
+ # Initially unchecked (from fixture setup with tax)
+ dialog.tax_checkbox.setChecked(False)
+ dialog._on_tax_toggled(False)
+
+ # Tax fields should be hidden
+ assert not dialog.tax_label.isVisible()
+ assert not dialog.tax_label_edit.isVisible()
+ assert not dialog.tax_rate_label.isVisible()
+ assert not dialog.tax_rate_spin.isVisible()
+
+ # Check the box
+ dialog.tax_checkbox.setChecked(True)
+ dialog._on_tax_toggled(True)
+
+ # Tax fields should be visible
+ assert dialog.tax_label.isVisible()
+ assert dialog.tax_label_edit.isVisible()
+ assert dialog.tax_rate_label.isVisible()
+ assert dialog.tax_rate_spin.isVisible()
+
+
+def test_invoice_dialog_on_client_company_changed(qtbot, invoice_dialog_setup):
+ """Test _on_client_company_changed method for autofill."""
+ setup = invoice_dialog_setup
+
+ # Create another project with different client
+ proj_id_2 = setup["db"].add_project("Project 2")
+ setup["db"].upsert_project_billing(
+ proj_id_2,
+ hourly_rate_cents=20000,
+ currency="EUR",
+ tax_label="GST",
+ tax_rate_percent=15.0,
+ client_name="Jane Smith",
+ client_company="Tech Industries",
+ client_address="456 Oak Ave",
+ client_email="jane@tech.com",
+ )
+
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ # Initially should have first project's client
+ assert dialog.client_name_edit.text() == "John Doe"
+
+ # Change to second company
+ dialog.client_company_combo.setCurrentText("Tech Industries")
+ dialog._on_client_company_changed("Tech Industries")
+
+ # Should autofill with second client's info
+ assert dialog.client_name_edit.text() == "Jane Smith"
+ assert dialog.client_addr_edit.toPlainText() == "456 Oak Ave"
+ assert dialog.client_email_edit.text() == "jane@tech.com"
+
+
+def test_invoice_dialog_create_due_date_reminder(qtbot, invoice_dialog_setup):
+ """Test _create_due_date_reminder method."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ due_date = (date.today() + timedelta(days=14)).isoformat()
+ invoice_number = "INV-TEST-001"
+ invoice_id = 999 # Fake invoice ID for testing
+
+ dialog._create_due_date_reminder(invoice_id, invoice_number, due_date)
+
+ # Check that reminder was created
+ reminders = setup["db"].get_all_reminders()
+ assert len(reminders) > 0
+
+ # Find our reminder
+ expected_text = _invoice_due_reminder_text("Test Project", invoice_number)
+ matching_reminders = [r for r in reminders if r.text == expected_text]
+ assert len(matching_reminders) == 1
+
+ reminder = matching_reminders[0]
+ assert reminder.reminder_type == ReminderType.ONCE
+ assert reminder.date_iso == due_date
+ assert reminder.time_str == _INVOICE_REMINDER_TIME
+
+
+# ============================================================================
+# Tests for InvoicesDialog
+# ============================================================================
+
+
+@pytest.fixture
+def invoices_dialog_setup(qtbot, fresh_db):
+ """Set up projects with invoices for InvoicesDialog testing."""
+ # Create projects
+ proj_id_1 = fresh_db.add_project("Project Alpha")
+ proj_id_2 = fresh_db.add_project("Project Beta")
+
+ # Create invoices for project 1
+ today = date.today()
+ for i in range(3):
+ issue_date = (today - timedelta(days=i * 7)).isoformat()
+ due_date = (today - timedelta(days=i * 7) + timedelta(days=14)).isoformat()
+ paid_at = today.isoformat() if i == 0 else None # First one is paid
+
+ fresh_db.create_invoice(
+ project_id=proj_id_1,
+ invoice_number=f"ALPHA-{i+1}",
+ issue_date=issue_date,
+ due_date=due_date,
+ currency="USD",
+ tax_label="VAT",
+ tax_rate_percent=20.0,
+ detail_mode=InvoiceDetailMode.DETAILED,
+ line_items=[("Development work", 10.0, 15000)], # 10 hours at $150/hr
+ time_log_ids=[],
+ )
+
+ # Update paid_at separately if needed
+ if paid_at:
+ invoice_rows = fresh_db.get_all_invoices(proj_id_1)
+ if invoice_rows:
+ inv_id = invoice_rows[0]["id"]
+ fresh_db.set_invoice_field_by_id(inv_id, "paid_at", paid_at)
+
+ # Create invoices for project 2
+ for i in range(2):
+ issue_date = (today - timedelta(days=i * 10)).isoformat()
+ due_date = (today - timedelta(days=i * 10) + timedelta(days=30)).isoformat()
+
+ fresh_db.create_invoice(
+ project_id=proj_id_2,
+ invoice_number=f"BETA-{i+1}",
+ issue_date=issue_date,
+ due_date=due_date,
+ currency="EUR",
+ tax_label=None,
+ tax_rate_percent=None,
+ detail_mode=InvoiceDetailMode.SUMMARY,
+ line_items=[("Consulting services", 10.0, 20000)], # 10 hours at $200/hr
+ time_log_ids=[],
+ )
+
+ return {
+ "db": fresh_db,
+ "proj_id_1": proj_id_1,
+ "proj_id_2": proj_id_2,
+ }
+
+
+def test_invoices_dialog_init(qtbot, invoices_dialog_setup):
+ """Test InvoicesDialog initialization."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"])
+ qtbot.addWidget(dialog)
+
+ assert dialog._db is setup["db"]
+ assert dialog.project_combo.count() >= 2 # 2 projects
+
+
+def test_invoices_dialog_init_with_project_id(qtbot, invoices_dialog_setup):
+ """Test InvoicesDialog initialization with specific project."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
+ qtbot.addWidget(dialog)
+
+ # Should select the specified project
+ current_proj = dialog._current_project()
+ assert current_proj == setup["proj_id_1"]
+
+
+def test_invoices_dialog_reload_projects(qtbot, invoices_dialog_setup):
+ """Test _reload_projects method."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"])
+ qtbot.addWidget(dialog)
+
+ initial_count = dialog.project_combo.count()
+ assert initial_count >= 2 # Should have 2 projects from setup
+
+ # Create a new project
+ setup["db"].add_project("Project Gamma")
+
+ # Reload projects
+ dialog._reload_projects()
+
+ # Should have one more project
+ assert dialog.project_combo.count() == initial_count + 1
+
+
+def test_invoices_dialog_current_project_specific(qtbot, invoices_dialog_setup):
+ """Test _current_project method when specific project is selected."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
+ qtbot.addWidget(dialog)
+
+ current_proj = dialog._current_project()
+ assert current_proj == setup["proj_id_1"]
+
+
+def test_invoices_dialog_reload_invoices_all_projects(qtbot, invoices_dialog_setup):
+ """Test _reload_invoices with first project selected by default."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"])
+ qtbot.addWidget(dialog)
+
+ # First project should be selected by default (Project Alpha with 3 invoices)
+ # The exact project depends on creation order, so just check we have some invoices
+ assert dialog.table.rowCount() in [2, 3] # Either proj1 (3) or proj2 (2)
+
+
+def test_invoices_dialog_reload_invoices_single_project(qtbot, invoices_dialog_setup):
+ """Test _reload_invoices with single project selected."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
+ qtbot.addWidget(dialog)
+
+ dialog._reload_invoices()
+
+ # Should show only 3 invoices from proj1
+ assert dialog.table.rowCount() == 3
+
+
+def test_invoices_dialog_on_project_changed(qtbot, invoices_dialog_setup):
+ """Test _on_project_changed method."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_2"])
+ qtbot.addWidget(dialog)
+
+ # Start with project 2 (2 invoices)
+ assert dialog.table.rowCount() == 2
+
+ # Find the index of project 1
+ for i in range(dialog.project_combo.count()):
+ if dialog.project_combo.itemData(i) == setup["proj_id_1"]:
+ dialog.project_combo.setCurrentIndex(i)
+ break
+
+ dialog._on_project_changed(dialog.project_combo.currentIndex())
+
+ # Should now show 3 invoices from proj1
+ assert dialog.table.rowCount() == 3
+
+
+def test_invoices_dialog_remove_invoice_due_reminder(qtbot, invoices_dialog_setup):
+ """Test _remove_invoice_due_reminder method."""
+ setup = invoices_dialog_setup
+
+ # Create a reminder for an invoice
+ due_date = (date.today() + timedelta(days=7)).isoformat()
+ invoice_number = "TEST-REMINDER-001"
+ project_name = "Project Alpha"
+
+ reminder_text = _invoice_due_reminder_text(project_name, invoice_number)
+ reminder = Reminder(
+ id=None,
+ text=reminder_text,
+ time_str=_INVOICE_REMINDER_TIME,
+ reminder_type=ReminderType.ONCE,
+ date_iso=due_date,
+ active=True,
+ )
+ reminder.id = setup["db"].save_reminder(reminder)
+
+ # Verify reminder exists
+ reminders = setup["db"].get_all_reminders()
+ assert len(reminders) == 1
+
+ # Create dialog and populate with invoices
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
+ qtbot.addWidget(dialog)
+
+ # Manually add a row to test the removal (simulating the invoice row)
+ row = dialog.table.rowCount()
+ dialog.table.insertRow(row)
+
+ # Set the project and invoice number items
+ from PySide6.QtWidgets import QTableWidgetItem
+
+ proj_item = QTableWidgetItem(project_name)
+ num_item = QTableWidgetItem(invoice_number)
+ dialog.table.setItem(row, dialog.COL_PROJECT, proj_item)
+ dialog.table.setItem(row, dialog.COL_NUMBER, num_item)
+
+ # Mock invoice_id
+ num_item.setData(Qt.ItemDataRole.UserRole, 999)
+
+ # Call the removal method
+ dialog._remove_invoice_due_reminder(row, 999)
+
+ # Reminder should be deleted
+ reminders_after = setup["db"].get_all_reminders()
+ assert len(reminders_after) == 0
+
+
+def test_invoices_dialog_on_item_changed_invoice_number(qtbot, invoices_dialog_setup):
+ """Test _on_item_changed for invoice number editing."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
+ qtbot.addWidget(dialog)
+
+ # Get the first row's invoice ID
+ num_item = dialog.table.item(0, dialog.COL_NUMBER)
+ inv_id = num_item.data(Qt.ItemDataRole.UserRole)
+
+ # Change the invoice number
+ num_item.setText("ALPHA-MODIFIED")
+
+ # Trigger the change handler
+ dialog._on_item_changed(num_item)
+
+ # Verify the change was saved to DB
+ invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "invoice_number")
+ assert invoice_data["invoice_number"] == "ALPHA-MODIFIED"
+
+
+def test_invoices_dialog_on_item_changed_empty_invoice_number(
+ qtbot, invoices_dialog_setup, monkeypatch
+):
+ """Test _on_item_changed rejects empty invoice number."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
+ qtbot.addWidget(dialog)
+
+ # Mock QMessageBox to auto-close
+ def mock_warning(*args, **kwargs):
+ return QMessageBox.Ok
+
+ monkeypatch.setattr(QMessageBox, "warning", mock_warning)
+
+ # Get the first row's invoice number item
+ num_item = dialog.table.item(0, dialog.COL_NUMBER)
+ original_number = num_item.text()
+
+ # Try to set empty invoice number
+ num_item.setText("")
+ dialog._on_item_changed(num_item)
+
+ # Should be reset to original
+ assert num_item.text() == original_number
+
+
+def test_invoices_dialog_on_item_changed_issue_date(qtbot, invoices_dialog_setup):
+ """Test _on_item_changed for issue date editing."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
+ qtbot.addWidget(dialog)
+
+ # Get the first row
+ num_item = dialog.table.item(0, dialog.COL_NUMBER)
+ inv_id = num_item.data(Qt.ItemDataRole.UserRole)
+
+ issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE)
+ new_date = "2024-01-15"
+ issue_item.setText(new_date)
+
+ dialog._on_item_changed(issue_item)
+
+ # Verify change was saved
+ invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "issue_date")
+ assert invoice_data["issue_date"] == new_date
+
+
+def test_invoices_dialog_on_item_changed_invalid_date(
+ qtbot, invoices_dialog_setup, monkeypatch
+):
+ """Test _on_item_changed rejects invalid date format."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
+ qtbot.addWidget(dialog)
+
+ # Mock QMessageBox
+ def mock_warning(*args, **kwargs):
+ return QMessageBox.Ok
+
+ monkeypatch.setattr(QMessageBox, "warning", mock_warning)
+
+ issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE)
+ original_date = issue_item.text()
+
+ # Try to set invalid date
+ issue_item.setText("not-a-date")
+ dialog._on_item_changed(issue_item)
+
+ # Should be reset to original
+ assert issue_item.text() == original_date
+
+
+def test_invoices_dialog_on_item_changed_due_before_issue(
+ qtbot, invoices_dialog_setup, monkeypatch
+):
+ """Test _on_item_changed rejects due date before issue date."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
+ qtbot.addWidget(dialog)
+
+ # Mock QMessageBox
+ def mock_warning(*args, **kwargs):
+ return QMessageBox.Ok
+
+ monkeypatch.setattr(QMessageBox, "warning", mock_warning)
+
+ # Set issue date
+ issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE)
+ issue_item.setText("2024-02-01")
+ dialog._on_item_changed(issue_item)
+
+ # Try to set due date before issue date
+ due_item = dialog.table.item(0, dialog.COL_DUE_DATE)
+ original_due = due_item.text()
+ due_item.setText("2024-01-01")
+ dialog._on_item_changed(due_item)
+
+ # Should be reset
+ assert due_item.text() == original_due
+
+
+def test_invoices_dialog_on_item_changed_currency(qtbot, invoices_dialog_setup):
+ """Test _on_item_changed for currency editing."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
+ qtbot.addWidget(dialog)
+
+ # Get the first row
+ num_item = dialog.table.item(0, dialog.COL_NUMBER)
+ inv_id = num_item.data(Qt.ItemDataRole.UserRole)
+
+ currency_item = dialog.table.item(0, dialog.COL_CURRENCY)
+ currency_item.setText("gbp") # lowercase
+
+ dialog._on_item_changed(currency_item)
+
+ # Should be normalized to uppercase
+ assert currency_item.text() == "GBP"
+
+ # Verify change was saved
+ invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "currency")
+ assert invoice_data["currency"] == "GBP"
+
+
+def test_invoices_dialog_on_item_changed_tax_rate(qtbot, invoices_dialog_setup):
+ """Test _on_item_changed for tax rate editing."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
+ qtbot.addWidget(dialog)
+
+ # Get the first row
+ num_item = dialog.table.item(0, dialog.COL_NUMBER)
+ inv_id = num_item.data(Qt.ItemDataRole.UserRole)
+
+ tax_rate_item = dialog.table.item(0, dialog.COL_TAX_RATE)
+ tax_rate_item.setText("15.5")
+
+ dialog._on_item_changed(tax_rate_item)
+
+ # Verify change was saved
+ invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "tax_rate_percent")
+ assert invoice_data["tax_rate_percent"] == 15.5
+
+
+def test_invoices_dialog_on_item_changed_invalid_tax_rate(
+ qtbot, invoices_dialog_setup, monkeypatch
+):
+ """Test _on_item_changed rejects invalid tax rate."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
+ qtbot.addWidget(dialog)
+
+ # Mock QMessageBox
+ def mock_warning(*args, **kwargs):
+ return QMessageBox.Ok
+
+ monkeypatch.setattr(QMessageBox, "warning", mock_warning)
+
+ tax_rate_item = dialog.table.item(0, dialog.COL_TAX_RATE)
+ original_rate = tax_rate_item.text()
+
+ # Try to set invalid tax rate
+ tax_rate_item.setText("not-a-number")
+ dialog._on_item_changed(tax_rate_item)
+
+ # Should be reset to original
+ assert tax_rate_item.text() == original_rate
+
+
+def test_invoices_dialog_on_item_changed_subtotal(qtbot, invoices_dialog_setup):
+ """Test _on_item_changed for subtotal editing."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
+ qtbot.addWidget(dialog)
+
+ # Get the first row
+ num_item = dialog.table.item(0, dialog.COL_NUMBER)
+ inv_id = num_item.data(Qt.ItemDataRole.UserRole)
+
+ subtotal_item = dialog.table.item(0, dialog.COL_SUBTOTAL)
+ subtotal_item.setText("1234.56")
+
+ dialog._on_item_changed(subtotal_item)
+
+ # Verify change was saved (in cents)
+ invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "subtotal_cents")
+ assert invoice_data["subtotal_cents"] == 123456
+
+ # Should be normalized to 2 decimals
+ assert subtotal_item.text() == "1234.56"
+
+
+def test_invoices_dialog_on_item_changed_invalid_amount(
+ qtbot, invoices_dialog_setup, monkeypatch
+):
+ """Test _on_item_changed rejects invalid amount."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
+ qtbot.addWidget(dialog)
+
+ # Mock QMessageBox
+ def mock_warning(*args, **kwargs):
+ return QMessageBox.Ok
+
+ monkeypatch.setattr(QMessageBox, "warning", mock_warning)
+
+ subtotal_item = dialog.table.item(0, dialog.COL_SUBTOTAL)
+ original_subtotal = subtotal_item.text()
+
+ # Try to set invalid amount
+ subtotal_item.setText("not-a-number")
+ dialog._on_item_changed(subtotal_item)
+
+ # Should be reset to original
+ assert subtotal_item.text() == original_subtotal
+
+
+def test_invoices_dialog_on_item_changed_paid_at_removes_reminder(
+ qtbot, invoices_dialog_setup
+):
+ """Test that marking invoice as paid removes due date reminder."""
+ setup = invoices_dialog_setup
+
+ # Create a reminder for an invoice
+ due_date = (date.today() + timedelta(days=7)).isoformat()
+ invoice_number = "ALPHA-1"
+ project_name = "Project Alpha"
+
+ reminder_text = _invoice_due_reminder_text(project_name, invoice_number)
+ reminder = Reminder(
+ id=None,
+ text=reminder_text,
+ time_str=_INVOICE_REMINDER_TIME,
+ reminder_type=ReminderType.ONCE,
+ date_iso=due_date,
+ active=True,
+ )
+ reminder.id = setup["db"].save_reminder(reminder)
+
+ # Verify reminder exists
+ reminders = setup["db"].get_all_reminders()
+ assert any(r.text == reminder_text for r in reminders)
+
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
+ qtbot.addWidget(dialog)
+
+ # Find the ALPHA-1 invoice row
+ for row in range(dialog.table.rowCount()):
+ num_item = dialog.table.item(row, dialog.COL_NUMBER)
+ if num_item and num_item.text() == "ALPHA-1":
+ # Mark as paid
+ paid_item = dialog.table.item(row, dialog.COL_PAID_AT)
+ paid_item.setText(date.today().isoformat())
+ dialog._on_item_changed(paid_item)
+ break
+
+ # Reminder should be removed
+ reminders_after = setup["db"].get_all_reminders()
+ assert not any(r.text == reminder_text for r in reminders_after)
+
+
+def test_invoices_dialog_ignores_changes_while_reloading(qtbot, invoices_dialog_setup):
+ """Test that _on_item_changed is ignored during reload."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
+ qtbot.addWidget(dialog)
+
+ # Set reloading flag
+ dialog._reloading_invoices = True
+
+ # Try to change an item
+ num_item = dialog.table.item(0, dialog.COL_NUMBER)
+ original_number = num_item.text()
+ inv_id = num_item.data(Qt.ItemDataRole.UserRole)
+
+ num_item.setText("SHOULD-BE-IGNORED")
+ dialog._on_item_changed(num_item)
+
+ # Change should not be saved to DB
+ invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "invoice_number")
+ assert invoice_data["invoice_number"] == original_number
+
+
+def test_invoice_dialog_update_mode_enabled(qtbot, invoice_dialog_setup):
+ """Test _update_mode_enabled method."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+ dialog.show()
+
+ # Initially detailed mode should be selected
+ assert dialog.rb_detailed.isChecked()
+
+ # Table should be enabled in detailed mode
+ assert dialog.table.isEnabled()
+
+ # Switch to summary mode
+ dialog.rb_summary.setChecked(True)
+ dialog._update_mode_enabled()
+
+ # Table should be disabled in summary mode
+ assert not dialog.table.isEnabled()
+
+
+def test_invoice_dialog_with_no_time_logs(qtbot, fresh_db):
+ """Test InvoiceDialog with project that has no time logs."""
+ proj_id = fresh_db.add_project("Empty Project")
+ today = date.today()
+ start = (today - timedelta(days=7)).isoformat()
+ end = today.isoformat()
+
+ dialog = InvoiceDialog(fresh_db, proj_id, start, end)
+ qtbot.addWidget(dialog)
+
+ # Should handle empty time logs gracefully
+ assert len(dialog._time_rows) == 0
+ assert dialog.table.rowCount() == 0
+
+
+def test_invoice_dialog_loads_client_company_list(qtbot, invoice_dialog_setup):
+ """Test that InvoiceDialog loads existing client companies."""
+ setup = invoice_dialog_setup
+
+ # Create another project with a different client company
+ proj_id_2 = setup["db"].add_project("Project 2")
+ setup["db"].upsert_project_billing(
+ proj_id_2,
+ hourly_rate_cents=10000,
+ currency="EUR",
+ tax_label="VAT",
+ tax_rate_percent=19.0,
+ client_name="Jane Doe",
+ client_company="Beta Corp",
+ client_address="456 Main St",
+ client_email="jane@beta.com",
+ )
+
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ # Should have both companies in the combo
+ companies = [
+ dialog.client_company_combo.itemText(i)
+ for i in range(dialog.client_company_combo.count())
+ ]
+ assert "Acme Corp" in companies
+ assert "Beta Corp" in companies
+
+
+def test_invoice_line_item_equality(app):
+ """Test InvoiceLineItem equality."""
+ item1 = InvoiceLineItem("Work", 5.0, 10000, 50000)
+ item2 = InvoiceLineItem("Work", 5.0, 10000, 50000)
+ item3 = InvoiceLineItem("Other", 5.0, 10000, 50000)
+
+ assert item1 == item2
+ assert item1 != item3
+
+
+def test_invoices_dialog_empty_database(qtbot, fresh_db):
+ """Test InvoicesDialog with no projects or invoices."""
+ dialog = InvoicesDialog(fresh_db)
+ qtbot.addWidget(dialog)
+
+ # Should have no projects in combo
+ assert dialog.project_combo.count() == 0
+ assert dialog.table.rowCount() == 0
+
+
+def test_invoice_dialog_tax_initially_disabled(qtbot, fresh_db):
+ """Test that tax fields are hidden when tax_rate_percent is None."""
+ proj_id = fresh_db.add_project("No Tax Project")
+ fresh_db.upsert_project_billing(
+ proj_id,
+ hourly_rate_cents=10000,
+ currency="USD",
+ tax_label="Tax",
+ tax_rate_percent=None, # No tax
+ client_name="Client",
+ client_company="Company",
+ client_address="Address",
+ client_email="email@test.com",
+ )
+
+ today = date.today()
+ start = (today - timedelta(days=1)).isoformat()
+ end = today.isoformat()
+
+ dialog = InvoiceDialog(fresh_db, proj_id, start, end)
+ qtbot.addWidget(dialog)
+ dialog.show()
+
+ # Tax checkbox should be unchecked
+ assert not dialog.tax_checkbox.isChecked()
+
+ # Tax fields should be hidden
+ assert not dialog.tax_label.isVisible()
+ assert not dialog.tax_label_edit.isVisible()
+ assert not dialog.tax_rate_label.isVisible()
+ assert not dialog.tax_rate_spin.isVisible()
+
+
+def test_invoice_dialog_dates_default_values(qtbot, invoice_dialog_setup):
+ """Test that issue and due dates have correct default values."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ # Issue date should be today
+ assert dialog.issue_date_edit.date() == QDate.currentDate()
+
+ # Due date should be 14 days from today
+ QDate.currentDate().addDays(14)
+ assert dialog.issue_date_edit.date() == QDate.currentDate()
+
+
+def test_invoice_dialog_checkbox_toggle_updates_totals(qtbot, invoice_dialog_setup):
+ """Test that unchecking a line item updates the total cost."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ dialog.rate_spin.setValue(100.0)
+ dialog._populate_detailed_rows(10000)
+ dialog.tax_checkbox.setChecked(False)
+
+ # Initial total: 3 rows * 2.5 hours * $100 = $750
+ dialog._recalc_totals()
+ assert "750.00" in dialog.subtotal_label.text()
+ assert "750.00" in dialog.total_label.text()
+
+ # Uncheck the first row
+ include_item = dialog.table.item(0, dialog.COL_INCLUDE)
+ include_item.setCheckState(Qt.Unchecked)
+
+ # Wait for signal processing
+ qtbot.wait(10)
+
+ # New total: 2 rows * 2.5 hours * $100 = $500
+ assert "500.00" in dialog.subtotal_label.text()
+ assert "500.00" in dialog.total_label.text()
+
+
+def test_invoice_dialog_checkbox_toggle_with_tax(qtbot, invoice_dialog_setup):
+ """Test that checkbox toggling works correctly with tax enabled."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ dialog.rate_spin.setValue(100.0)
+ dialog._populate_detailed_rows(10000)
+ dialog.tax_checkbox.setChecked(True)
+ dialog.tax_rate_spin.setValue(10.0)
+
+ # Initial: 3 rows * 2.5 hours * $100 = $750
+ # Tax: $750 * 10% = $75
+ # Total: $825
+ dialog._recalc_totals()
+ assert "750.00" in dialog.subtotal_label.text()
+ assert "75.00" in dialog.tax_label_total.text()
+ assert "825.00" in dialog.total_label.text()
+
+ # Uncheck two rows
+ dialog.table.item(0, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked)
+ dialog.table.item(1, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked)
+
+ # Wait for signal processing
+ qtbot.wait(10)
+
+ # New total: 1 row * 2.5 hours * $100 = $250
+ # Tax: $250 * 10% = $25
+ # Total: $275
+ assert "250.00" in dialog.subtotal_label.text()
+ assert "25.00" in dialog.tax_label_total.text()
+ assert "275.00" in dialog.total_label.text()
+
+
+def test_invoice_dialog_rechecking_items_updates_totals(qtbot, invoice_dialog_setup):
+ """Test that rechecking a previously unchecked item updates totals."""
+ setup = invoice_dialog_setup
+ dialog = InvoiceDialog(
+ setup["db"],
+ setup["proj_id"],
+ setup["start_date"],
+ setup["end_date"],
+ setup["time_rows"],
+ )
+ qtbot.addWidget(dialog)
+
+ dialog.rate_spin.setValue(100.0)
+ dialog._populate_detailed_rows(10000)
+ dialog.tax_checkbox.setChecked(False)
+
+ # Uncheck all items
+ for row in range(dialog.table.rowCount()):
+ dialog.table.item(row, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked)
+
+ qtbot.wait(10)
+
+ # Total should be 0
+ assert "0.00" in dialog.total_label.text()
+
+ # Re-check first item
+ dialog.table.item(0, dialog.COL_INCLUDE).setCheckState(Qt.Checked)
+ qtbot.wait(10)
+
+ # Total should be 1 row * 2.5 hours * $100 = $250
+ assert "250.00" in dialog.total_label.text()
+
+
+def test_invoices_dialog_select_initial_project(qtbot, invoices_dialog_setup):
+ """Test _select_initial_project method."""
+ setup = invoices_dialog_setup
+ dialog = InvoicesDialog(setup["db"])
+ qtbot.addWidget(dialog)
+
+ # Initially should have first project selected (either proj1 or proj2)
+ initial_proj = dialog._current_project()
+ assert initial_proj in [setup["proj_id_1"], setup["proj_id_2"]]
+
+ # Select specific project
+ dialog._select_initial_project(setup["proj_id_2"])
+
+ # Should now have proj_id_2 selected
+ assert dialog._current_project() == setup["proj_id_2"]
diff --git a/tests/test_key_prompt.py b/tests/test_key_prompt.py
index 07a0044..9aedffb 100644
--- a/tests/test_key_prompt.py
+++ b/tests/test_key_prompt.py
@@ -1,9 +1,204 @@
from bouquin.key_prompt import KeyPrompt
+from PySide6.QtCore import QTimer
+from PySide6.QtWidgets import QFileDialog, QLineEdit
def test_key_prompt_roundtrip(qtbot):
kp = KeyPrompt()
qtbot.addWidget(kp)
kp.show()
- kp.edit.setText("swordfish")
+ kp.key_entry.setText("swordfish")
assert kp.key() == "swordfish"
+
+
+def test_key_prompt_with_db_path_browse(qtbot, app, tmp_path, monkeypatch):
+ """Test KeyPrompt with DB path selection - covers lines 57-67"""
+ test_db = tmp_path / "test.db"
+ test_db.touch()
+
+ # Create prompt with show_db_change=True
+ prompt = KeyPrompt(show_db_change=True)
+ qtbot.addWidget(prompt)
+
+ # Mock the file dialog to return a file
+ def mock_get_open_filename(*args, **kwargs):
+ return str(test_db), "SQLCipher DB (*.db)"
+
+ monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
+
+ # Simulate clicking the browse button
+ # Find the browse button by looking through the widget's children
+ browse_btn = None
+ for child in prompt.findChildren(object):
+ if hasattr(child, "clicked") and hasattr(child, "text"):
+ if (
+ "select" in str(child.text()).lower()
+ or "browse" in str(child.text()).lower()
+ ):
+ browse_btn = child
+ break
+
+ if browse_btn:
+ browse_btn.click()
+ qtbot.wait(50)
+
+ # Verify the path was set
+ assert prompt.path_edit is not None
+ assert str(test_db) in prompt.path_edit.text()
+
+
+def test_key_prompt_with_db_path_no_file_selected(qtbot, app, tmp_path, monkeypatch):
+ """Test KeyPrompt when cancel is clicked in file dialog - covers line 64 condition"""
+ # Create prompt with show_db_change=True
+ prompt = KeyPrompt(show_db_change=True)
+ qtbot.addWidget(prompt)
+
+ # Mock the file dialog to return empty string (user cancelled)
+ def mock_get_open_filename(*args, **kwargs):
+ return "", ""
+
+ monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
+
+ # Store original path text
+ original_text = prompt.path_edit.text() if prompt.path_edit else ""
+
+ # Simulate clicking the browse button
+ browse_btn = None
+ for child in prompt.findChildren(object):
+ if hasattr(child, "clicked") and hasattr(child, "text"):
+ if (
+ "select" in str(child.text()).lower()
+ or "browse" in str(child.text()).lower()
+ ):
+ browse_btn = child
+ break
+
+ if browse_btn:
+ browse_btn.click()
+ qtbot.wait(50)
+
+ # Path should not have changed since no file was selected
+ if prompt.path_edit:
+ assert prompt.path_edit.text() == original_text
+
+
+def test_key_prompt_with_existing_db_path(qtbot, app, tmp_path):
+ """Test KeyPrompt with existing DB path provided"""
+ test_db = tmp_path / "existing.db"
+ test_db.touch()
+
+ prompt = KeyPrompt(show_db_change=True, initial_db_path=test_db)
+ qtbot.addWidget(prompt)
+
+ # Verify the path is pre-filled
+ assert prompt.path_edit is not None
+ assert str(test_db) in prompt.path_edit.text()
+
+
+def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app):
+ """Test KeyPrompt with show_db_change but no initial_db_path"""
+ prompt = KeyPrompt(show_db_change=True, initial_db_path=None)
+ qtbot.addWidget(prompt)
+
+ # Path edit should exist but be empty
+ assert prompt.path_edit is not None
+ assert prompt.path_edit.text() == ""
+
+
+def test_key_prompt_accept_with_valid_key(qtbot, app):
+ """Test accepting prompt with valid key"""
+ prompt = KeyPrompt()
+ qtbot.addWidget(prompt)
+
+ # Enter a key
+ prompt.key_entry.setText("test-key-123")
+
+ # Accept
+ QTimer.singleShot(0, prompt.accept)
+ qtbot.wait(50)
+
+ assert prompt.key_entry.text() == "test-key-123"
+
+
+def test_key_prompt_without_db_change(qtbot, app):
+ """Test KeyPrompt without show_db_change"""
+ prompt = KeyPrompt(show_db_change=False)
+ qtbot.addWidget(prompt)
+
+ # Path edit should not exist
+ assert prompt.path_edit is None
+
+
+def test_key_prompt_password_visibility(qtbot, app):
+ """Test password entry mode"""
+ prompt = KeyPrompt()
+ qtbot.addWidget(prompt)
+
+ # Initially should be password mode
+ assert prompt.key_entry.echoMode() == QLineEdit.EchoMode.Password
+
+ # Enter some text
+ prompt.key_entry.setText("secret")
+
+ # The text should be obscured
+ assert prompt.key_entry.echoMode() == QLineEdit.EchoMode.Password
+
+
+def test_key_prompt_key_method(qtbot, app):
+ """Test the key() method returns entered text"""
+ prompt = KeyPrompt()
+ qtbot.addWidget(prompt)
+
+ prompt.key_entry.setText("my-secret-key")
+
+ assert prompt.key() == "my-secret-key"
+
+
+def test_key_prompt_db_path_method(qtbot, app, tmp_path):
+ """Test the db_path() method returns selected path"""
+ test_db = tmp_path / "test.db"
+ test_db.touch()
+
+ prompt = KeyPrompt(show_db_change=True, initial_db_path=test_db)
+ qtbot.addWidget(prompt)
+
+ # Should return the db_path
+ assert prompt.db_path() == test_db
+
+
+def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
+ """Test browsing when initial_db_path is set"""
+ initial_db = tmp_path / "initial.db"
+ initial_db.touch()
+
+ new_db = tmp_path / "new.db"
+ new_db.touch()
+
+ prompt = KeyPrompt(show_db_change=True, initial_db_path=initial_db)
+ qtbot.addWidget(prompt)
+
+ # Mock the file dialog to return a different file
+ def mock_get_open_filename(*args, **kwargs):
+ # Verify that start_dir was passed correctly
+ return str(new_db), "SQLCipher DB (*.db)"
+
+ monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
+
+ # Find and click browse button
+ browse_btn = None
+ for child in prompt.findChildren(object):
+ if hasattr(child, "clicked") and hasattr(child, "text"):
+ if (
+ "select" in str(child.text()).lower()
+ or "browse" in str(child.text()).lower()
+ ):
+ browse_btn = child
+ break
+
+ if browse_btn:
+ browse_btn.click()
+ qtbot.wait(50)
+
+ # Verify new path was set
+ assert str(new_db) in prompt.path_edit.text()
+ assert prompt.db_path() == new_db
diff --git a/tests/test_lock_overlay.py b/tests/test_lock_overlay.py
index 05de5f9..46b3cfd 100644
--- a/tests/test_lock_overlay.py
+++ b/tests/test_lock_overlay.py
@@ -1,7 +1,7 @@
+from bouquin.lock_overlay import LockOverlay
+from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtCore import QEvent
from PySide6.QtWidgets import QWidget
-from bouquin.lock_overlay import LockOverlay
-from bouquin.theme import ThemeManager, ThemeConfig, Theme
def test_lock_overlay_reacts_to_theme(app, qtbot):
diff --git a/tests/test_main.py b/tests/test_main.py
index 94d3b11..5bfb774 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1,5 +1,6 @@
import importlib
import runpy
+
import pytest
@@ -42,6 +43,9 @@ def test_main_creates_and_shows(monkeypatch):
def setOrganizationName(self, *_):
pass
+ def setWindowIcon(self, *_):
+ pass
+
def exec(self):
return 0
diff --git a/tests/test_main_window.py b/tests/test_main_window.py
index 8ce5564..6c09e71 100644
--- a/tests/test_main_window.py
+++ b/tests/test_main_window.py
@@ -1,28 +1,33 @@
-import pytest
import importlib.metadata
-
+from datetime import date, timedelta
from pathlib import Path
+from unittest.mock import Mock, patch
import bouquin.main_window as mwmod
-from bouquin.main_window import MainWindow
-from bouquin.theme import Theme, ThemeConfig, ThemeManager
-from bouquin.settings import get_settings
-from bouquin.key_prompt import KeyPrompt
+import bouquin.version_check as version_check
+import pytest
from bouquin.db import DBConfig, DBManager
-from PySide6.QtCore import QEvent, QDate, QTimer, Qt, QPoint, QRect
-from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox, QDialog
-from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent
-
-from unittest.mock import Mock, patch
+from bouquin.key_prompt import KeyPrompt
+from bouquin.main_window import MainWindow
+from bouquin.settings import get_settings
+from bouquin.theme import Theme, ThemeConfig, ThemeManager
+from PySide6.QtCore import QDate, QEvent, QPoint, QRect, Qt, QTimer
+from PySide6.QtGui import QCloseEvent, QKeyEvent, QMouseEvent, QTextCursor
+from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QTableView, QWidget
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings()
- s.setValue("db/path", str(tmp_db_cfg.path))
+ s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
s.setValue("ui/move_todos", True)
+ s.setValue("ui/tags", True)
+ s.setValue("ui/time_log", True)
+ s.setValue("ui/reminders", True)
+ s.setValue("ui/locale", "en")
+ s.setValue("ui/font_size", 11)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
@@ -48,7 +53,7 @@ def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
def _auto_accept_keyprompt():
for wdg in QApplication.topLevelWidgets():
if isinstance(wdg, KeyPrompt):
- wdg.edit.setText(tmp_db_cfg.key)
+ wdg.key_entry.setText(tmp_db_cfg.key)
wdg.accept()
w._enter_lock()
@@ -59,7 +64,7 @@ def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings()
- s.setValue("db/path", str(tmp_db_cfg.path))
+ s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/move_todos", True)
s.setValue("ui/theme", "light")
@@ -73,14 +78,14 @@ def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
qtbot.addWidget(w)
w.show()
- w._load_yesterday_todos()
+ w._load_unchecked_todos()
assert "carry me" in w.editor.to_markdown()
y_txt = fresh_db.get_entry(y)
assert "carry me" not in y_txt or "- [ ]" not in y_txt
-def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch):
+def test_open_docs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
@@ -101,16 +106,12 @@ def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch)
t = str(text)
if "wiki" in t:
called["docs"] = True
- if "forms/mig5/contact" in t or "contact" in t:
- called["bugs"] = True
return 0
monkeypatch.setattr(mwmod, "QMessageBox", DummyMB, raising=True) # capture warnings
- # Trigger both actions
w._open_docs()
- w._open_bugs()
- assert called["docs"] and called["bugs"]
+ assert called["docs"]
def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
@@ -122,7 +123,7 @@ def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
from bouquin.settings import get_settings
s = get_settings()
- s.setValue("db/path", str(fresh_db.cfg.path))
+ s.setValue("db/default_db", str(fresh_db.cfg.path))
s.setValue("db/key", fresh_db.cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
@@ -196,7 +197,7 @@ def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch):
from bouquin.settings import get_settings
s = get_settings()
- s.setValue("db/path", str(fresh_db.cfg.path))
+ s.setValue("db/default_db", str(fresh_db.cfg.path))
s.setValue("db/key", fresh_db.cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
@@ -251,7 +252,7 @@ def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch):
from bouquin.settings import get_settings
s = get_settings()
- s.setValue("db/path", str(fresh_db.cfg.path))
+ s.setValue("db/default_db", str(fresh_db.cfg.path))
s.setValue("db/key", fresh_db.cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
@@ -472,8 +473,9 @@ def test_try_connect_maps_errors(
mwmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True
)
- ok = w._try_connect()
- assert ok is False
+ w._try_connect()
+
+ # And we still showed the right error message
assert "database" in shown["title"].lower()
if expect_key_msg:
assert "key" in shown["text"].lower()
@@ -499,6 +501,9 @@ def test_prompt_for_key_cancel_returns_false(qtbot, tmp_db_cfg, app, monkeypatch
def key(self):
return ""
+ def db_path(self) -> Path | None:
+ return "foo.db"
+
monkeypatch.setattr(mwmod, "KeyPrompt", CancelPrompt, raising=True)
assert w._prompt_for_key_until_valid(first_time=False) is False
@@ -517,6 +522,9 @@ def test_prompt_for_key_accept_then_connects(qtbot, tmp_db_cfg, app, monkeypatch
def key(self):
return "abc"
+ def db_path(self) -> Path | None:
+ return "foo.db"
+
monkeypatch.setattr(mwmod, "KeyPrompt", OKPrompt, raising=True)
monkeypatch.setattr(w, "_try_connect", lambda: True, raising=True)
assert w._prompt_for_key_until_valid(first_time=True) is True
@@ -878,9 +886,7 @@ def test_backup_success_and_error(qtbot, tmp_db_cfg, app, monkeypatch, tmp_path)
# ---- Help openers (1152-1169) ----
-def test_open_docs_and_bugs_show_warning_on_failure(
- qtbot, tmp_db_cfg, app, monkeypatch
-):
+def test_open_docs_show_warning_on_failure(qtbot, tmp_db_cfg, app, monkeypatch):
w = _make_main_window(tmp_db_cfg, app, monkeypatch)
qtbot.addWidget(w)
@@ -892,41 +898,92 @@ def test_open_docs_and_bugs_show_warning_on_failure(
def warn(parent, title, text, *a, **k):
if "documentation" in title.lower():
seen["docs"] = True
- if "bug" in title.lower():
- seen["bugs"] = True
return 0
monkeypatch.setattr(
mwmod, "QMessageBox", type("MB", (), {"warning": staticmethod(warn)})
)
w._open_docs()
- w._open_bugs()
- assert seen["docs"] and seen["bugs"]
+ assert seen["docs"]
def test_open_version(qtbot, tmp_db_cfg, app, monkeypatch):
w = _make_main_window(tmp_db_cfg, app, monkeypatch)
qtbot.addWidget(w)
- called = {"title": None, "text": None}
+ called = {"title": None, "text": None, "check_called": False}
- def fake_information(parent, title, text, *a, **k):
- called["title"] = title
- called["text"] = text
- # Return value of QMessageBox.information is an int; 0 is fine.
- return 0
+ # Fake QMessageBox that mimics the bits VersionChecker.show_version_dialog uses
+ class FakeMessageBox:
+ # provide the enum attributes the code references
+ Information = 0
+ ActionRole = 1
+ Close = 2
- # Patch whichever one you actually use in _open_version
- monkeypatch.setattr(QMessageBox, "information", fake_information)
+ def __init__(self, parent=None):
+ self._parent = parent
+ self._icon = None
+ self._title = ""
+ self._text = ""
+ self._buttons = []
+ self._clicked = None
+ def setIcon(self, icon):
+ self._icon = icon
+
+ def setIconPixmap(self, icon):
+ self._icon = icon
+
+ def setWindowTitle(self, title):
+ self._title = title
+ called["title"] = title
+
+ def setText(self, text):
+ self._text = text
+ called["text"] = text
+
+ def addButton(self, *args, **kwargs):
+ # We don't care about the label/role, we just need a distinct object
+ btn = object()
+ self._buttons.append(btn)
+ return btn
+
+ def exec(self):
+ # Simulate user clicking the *Close* button, i.e. the second button
+ if self._buttons:
+ # show_version_dialog adds buttons in order:
+ # 0 -> "Check for updates"
+ # 1 -> Close
+ self._clicked = self._buttons[-1]
+
+ def clickedButton(self):
+ return self._clicked
+
+ # Patch the QMessageBox used *inside* version_check.py
+ monkeypatch.setattr(version_check, "QMessageBox", FakeMessageBox)
+
+ # Optional: track if check_for_updates would be called
+ def fake_check_for_updates(self):
+ called["check_called"] = True
+
+ monkeypatch.setattr(
+ version_check.VersionChecker, "check_for_updates", fake_check_for_updates
+ )
+
+ # Call the entrypoint
w._open_version()
+ # Assertions: title and text got set correctly
assert called["title"] is not None
assert "version" in called["title"].lower()
+
version = importlib.metadata.version("bouquin")
assert version in called["text"]
+ # And we simulated closing, so "Check for updates" should not have fired
+ assert called["check_called"] is False
+
# ---- Idle/lock/event filter helpers (1176, 1181-1187, 1193-1202, 1231-1233) ----
@@ -1630,6 +1687,7 @@ def test_main_window_settings_path_change_success(
new_cfg.theme = "light"
new_cfg.move_todos = True
new_cfg.locale = "en"
+ new_cfg.font_size = 11
mock_instance.config = new_cfg
mock_dialog.return_value = mock_instance
@@ -1673,6 +1731,7 @@ def test_main_window_settings_path_change_failure(
new_cfg.theme = "light"
new_cfg.move_todos = True
new_cfg.locale = "en"
+ new_cfg.font_size = 11
mock_instance.config = new_cfg
mock_dialog.return_value = mock_instance
@@ -1714,6 +1773,7 @@ def test_main_window_settings_no_path_change(app, fresh_db, tmp_db_cfg, monkeypa
new_cfg.theme = "dark" # Changed
new_cfg.move_todos = False # Changed
new_cfg.locale = "fr" # Changed
+ new_cfg.font_size = 12 # Changed
mock_instance.config = new_cfg
mock_dialog.return_value = mock_instance
@@ -1725,6 +1785,7 @@ def test_main_window_settings_no_path_change(app, fresh_db, tmp_db_cfg, monkeypa
assert window.cfg.idle_minutes == 20
assert window.cfg.theme == "dark"
assert window.cfg.path == old_path
+ assert window.cfg.font_size == 12
def test_main_window_settings_cancelled(app, fresh_db, tmp_db_cfg, monkeypatch):
@@ -1795,3 +1856,575 @@ def test_main_window_update_tag_views_no_tags_widget(
window._update_tag_views_for_date("2024-01-15")
assert True
+
+
+def test_main_window_without_tags(qtbot, app, tmp_db_cfg):
+ """Test main window when tags feature is disabled."""
+ s = get_settings()
+ s.setValue("db/default_db", str(tmp_db_cfg.path))
+ s.setValue("db/key", tmp_db_cfg.key)
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+ s.setValue("ui/move_todos", True)
+ s.setValue("ui/tags", False) # Disabled
+ s.setValue("ui/time_log", True)
+ s.setValue("ui/reminders", True)
+ s.setValue("ui/locale", "en")
+ s.setValue("ui/font_size", 11)
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ window = MainWindow(themes=themes)
+ qtbot.addWidget(window)
+ window.show()
+
+ # Verify tags widget is hidden
+ assert window.tags.isHidden()
+
+
+def test_main_window_without_time_log(qtbot, app, tmp_db_cfg):
+ """Test main window when time_log feature is disabled."""
+ s = get_settings()
+ s.setValue("db/default_db", str(tmp_db_cfg.path))
+ s.setValue("db/key", tmp_db_cfg.key)
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+ s.setValue("ui/move_todos", True)
+ s.setValue("ui/tags", True)
+ s.setValue("ui/time_log", False) # Disabled
+ s.setValue("ui/reminders", True)
+ s.setValue("ui/locale", "en")
+ s.setValue("ui/font_size", 11)
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ window = MainWindow(themes=themes)
+ qtbot.addWidget(window)
+ window.show()
+
+ # Verify time_log widget is hidden
+ assert window.time_log.isHidden()
+ assert not window.toolBar.actTimer.isVisible()
+
+
+def test_main_window_without_documents(qtbot, app, tmp_db_cfg):
+ """Test main window when documents feature is disabled."""
+ s = get_settings()
+ s.setValue("db/default_db", str(tmp_db_cfg.path))
+ s.setValue("db/key", tmp_db_cfg.key)
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+ s.setValue("ui/move_todos", True)
+ s.setValue("ui/tags", True)
+ s.setValue("ui/time_log", True)
+ s.setValue("ui/reminders", True)
+ s.setValue("ui/documents", False) # Disabled
+ s.setValue("ui/locale", "en")
+ s.setValue("ui/font_size", 11)
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ window = MainWindow(themes=themes)
+ qtbot.addWidget(window)
+ window.show()
+
+ # Verify documents widget is hidden
+ assert window.todays_documents.isHidden()
+ assert not window.toolBar.actDocuments.isVisible()
+
+
+def test_export_csv_format(qtbot, app, tmp_path, monkeypatch):
+ """Test exporting to CSV format - covers export path lines"""
+ db_path = tmp_path / "notebook.db"
+ s = get_settings()
+ s.setValue("db/default_db", str(db_path))
+ s.setValue("db/key", "test-key")
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ w = MainWindow(themes=themes)
+ qtbot.addWidget(w)
+ w.show()
+
+ # Add some data
+ w.db.save_new_version("2024-01-01", "Test content", "test")
+
+ # Mock file dialog to return CSV
+ dest = tmp_path / "export_test.csv"
+ monkeypatch.setattr(
+ mwmod.QFileDialog,
+ "getSaveFileName",
+ staticmethod(lambda *a, **k: (str(dest), "CSV (*.csv)")),
+ )
+
+ # Mock QMessageBox to auto-accept
+ monkeypatch.setattr(
+ mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
+ )
+ monkeypatch.setattr(
+ mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False
+ )
+
+ w._export()
+ assert dest.exists()
+
+
+def test_settings_dialog_with_locale_change(qtbot, app, tmp_path, monkeypatch):
+ """Test opening settings dialog and changing locale - covers settings dialog paths"""
+ db_path = tmp_path / "notebook.db"
+ s = get_settings()
+ s.setValue("db/default_db", str(db_path))
+ s.setValue("db/key", "test-key")
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ w = MainWindow(themes=themes)
+ qtbot.addWidget(w)
+ w.show()
+
+ # Mock the settings dialog to auto-accept
+ from bouquin.settings_dialog import SettingsDialog
+
+ SettingsDialog.exec
+
+ def fake_exec(self):
+ # Change locale before accepting
+ idx = self.locale_combobox.findData("fr")
+ if idx >= 0:
+ self.locale_combobox.setCurrentIndex(idx)
+ return mwmod.QDialog.Accepted
+
+ monkeypatch.setattr(SettingsDialog, "exec", fake_exec)
+
+ w._open_settings()
+ qtbot.wait(50)
+
+
+def test_statistics_dialog_open(qtbot, app, tmp_path, monkeypatch):
+ """Test opening statistics dialog - covers statistics dialog paths"""
+ db_path = tmp_path / "notebook.db"
+ s = get_settings()
+ s.setValue("db/default_db", str(db_path))
+ s.setValue("db/key", "test-key")
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ w = MainWindow(themes=themes)
+ qtbot.addWidget(w)
+ w.show()
+
+ # Add some data
+ w.db.save_new_version("2024-01-01", "Test content", "test")
+
+ from bouquin.statistics_dialog import StatisticsDialog
+
+ StatisticsDialog.exec
+
+ def fake_exec(self):
+ # Just accept immediately
+ return mwmod.QDialog.Accepted
+
+ monkeypatch.setattr(StatisticsDialog, "exec", fake_exec)
+
+ w._open_statistics()
+ qtbot.wait(50)
+
+
+def test_bug_report_dialog_open(qtbot, app, tmp_path, monkeypatch):
+ """Test opening bug report dialog"""
+ db_path = tmp_path / "notebook.db"
+ s = get_settings()
+ s.setValue("db/default_db", str(db_path))
+ s.setValue("db/key", "test-key")
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ w = MainWindow(themes=themes)
+ qtbot.addWidget(w)
+ w.show()
+
+ from bouquin.bug_report_dialog import BugReportDialog
+
+ BugReportDialog.exec
+
+ def fake_exec(self):
+ return mwmod.QDialog.Rejected
+
+ monkeypatch.setattr(BugReportDialog, "exec", fake_exec)
+
+ w._open_bugs()
+ qtbot.wait(50)
+
+
+def test_history_dialog_open_and_restore(qtbot, app, tmp_path, monkeypatch):
+ """Test opening history dialog and restoring a version"""
+ db_path = tmp_path / "notebook.db"
+ s = get_settings()
+ s.setValue("db/default_db", str(db_path))
+ s.setValue("db/key", "test-key")
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ w = MainWindow(themes=themes)
+ qtbot.addWidget(w)
+ w.show()
+
+ # Add some data
+ date_str = QDate.currentDate().toString("yyyy-MM-dd")
+ w.db.save_new_version(date_str, "Version 1", "v1")
+ w.db.save_new_version(date_str, "Version 2", "v2")
+
+ from bouquin.history_dialog import HistoryDialog
+
+ def fake_exec(self):
+ # Simulate selecting first version and accepting
+ if self.list.count() > 0:
+ self.list.setCurrentRow(0)
+ self._revert()
+ return mwmod.QDialog.Accepted
+
+ monkeypatch.setattr(HistoryDialog, "exec", fake_exec)
+
+ w._open_history()
+ qtbot.wait(50)
+
+
+def test_goto_today_button(qtbot, app, tmp_path):
+ """Test going to today's date"""
+ db_path = tmp_path / "notebook.db"
+ s = get_settings()
+ s.setValue("db/default_db", str(db_path))
+ s.setValue("db/key", "test-key")
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ w = MainWindow(themes=themes)
+ qtbot.addWidget(w)
+ w.show()
+
+ # Move to a different date
+ past_date = QDate.currentDate().addDays(-30)
+ w.calendar.setSelectedDate(past_date)
+
+ # Go back to today
+ w._adjust_today()
+ qtbot.wait(50)
+
+ assert w.calendar.selectedDate() == QDate.currentDate()
+
+
+def test_adjust_font_size(qtbot, app, tmp_path):
+ """Test adjusting font size"""
+ db_path = tmp_path / "notebook.db"
+ s = get_settings()
+ s.setValue("db/default_db", str(db_path))
+ s.setValue("db/key", "test-key")
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+ s.setValue("ui/font_size", 12)
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ w = MainWindow(themes=themes)
+ qtbot.addWidget(w)
+ w.show()
+
+ initial_size = w.editor.font().pointSize()
+
+ # Increase font size
+ w._on_font_larger_requested()
+ qtbot.wait(50)
+ assert w.editor.font().pointSize() > initial_size
+
+ # Decrease font size
+ w._on_font_smaller_requested()
+ qtbot.wait(50)
+
+
+def test_calendar_date_selection(qtbot, app, tmp_path):
+ """Test selecting a date from calendar"""
+ db_path = tmp_path / "notebook.db"
+ s = get_settings()
+ s.setValue("db/default_db", str(db_path))
+ s.setValue("db/key", "test-key")
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ w = MainWindow(themes=themes)
+ qtbot.addWidget(w)
+ w.show()
+
+ # Select a specific date
+ test_date = QDate(2024, 6, 15)
+ w.calendar.setSelectedDate(test_date)
+ qtbot.wait(50)
+
+ # The window should load that date
+ assert test_date.toString("yyyy-MM-dd") in str(w._current_date_iso())
+
+
+def test_main_window_without_reminders(qtbot, app, tmp_db_cfg):
+ """Test main window when reminders feature is disabled."""
+ s = get_settings()
+ s.setValue("db/default_db", str(tmp_db_cfg.path))
+ s.setValue("db/key", tmp_db_cfg.key)
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+ s.setValue("ui/move_todos", True)
+ s.setValue("ui/tags", True)
+ s.setValue("ui/time_log", True)
+ s.setValue("ui/reminders", False) # Disabled
+ s.setValue("ui/locale", "en")
+ s.setValue("ui/font_size", 11)
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ window = MainWindow(themes=themes)
+ qtbot.addWidget(window)
+ window.show()
+
+ # Verify reminders widget is hidden
+ assert window.upcoming_reminders.isHidden()
+ assert not window.toolBar.actAlarm.isVisible()
+
+
+def test_close_current_tab(qtbot, app, tmp_db_cfg, fresh_db):
+ """Test closing the current tab via _close_current_tab."""
+ s = get_settings()
+ s.setValue("db/default_db", str(tmp_db_cfg.path))
+ s.setValue("db/key", tmp_db_cfg.key)
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+ s.setValue("ui/move_todos", True)
+ s.setValue("ui/tags", True)
+ s.setValue("ui/time_log", True)
+ s.setValue("ui/reminders", True)
+ s.setValue("ui/locale", "en")
+ s.setValue("ui/font_size", 11)
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ window = MainWindow(themes=themes)
+ qtbot.addWidget(window)
+ window.show()
+
+ # Open multiple tabs
+ today = date.today().isoformat()
+ tomorrow = (date.today() + timedelta(days=1)).isoformat()
+
+ window._open_date_in_tab(QDate.fromString(today, "yyyy-MM-dd"))
+ window._open_date_in_tab(QDate.fromString(tomorrow, "yyyy-MM-dd"))
+
+ initial_count = window.tab_widget.count()
+ assert initial_count >= 2
+
+ # Close current tab
+ window._close_current_tab()
+
+ # Verify tab was closed
+ assert window.tab_widget.count() == initial_count - 1
+
+
+def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time):
+ """Test parsing inline alarms from markdown (⏰ HH:MM format)."""
+ from PySide6.QtCore import QTime
+
+ s = get_settings()
+ s.setValue("db/default_db", str(tmp_db_cfg.path))
+ s.setValue("db/key", tmp_db_cfg.key)
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+ s.setValue("ui/move_todos", True)
+ s.setValue("ui/tags", True)
+ s.setValue("ui/time_log", True)
+ s.setValue("ui/reminders", True)
+ s.setValue("ui/locale", "en")
+ s.setValue("ui/font_size", 11)
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ window = MainWindow(themes=themes)
+ qtbot.addWidget(window)
+ window.show()
+
+ # Open today's date
+ today_qdate = QDate.currentDate()
+ window._open_date_in_tab(today_qdate)
+
+ # Set content with a future alarm
+ future_time = QTime.currentTime().addSecs(3600)
+ alarm_text = f"Do something ⏰ {future_time.hour():02d}:{future_time.minute():02d}"
+
+ # Set the editor's current_date attribute
+ window.editor.current_date = today_qdate
+ window.editor.setPlainText(alarm_text)
+
+ # Clear any existing timers
+ window._reminder_timers = []
+
+ # Trigger alarm parsing
+ window._rebuild_reminders_for_today()
+
+ # Verify timer was created (not DB reminder)
+ assert len(window._reminder_timers) > 0
+
+
+def test_parse_today_alarms_invalid_time(qtbot, app, tmp_db_cfg, fresh_db):
+ """Test that invalid time formats are skipped."""
+ s = get_settings()
+ s.setValue("db/default_db", str(tmp_db_cfg.path))
+ s.setValue("db/key", tmp_db_cfg.key)
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+ s.setValue("ui/move_todos", True)
+ s.setValue("ui/tags", True)
+ s.setValue("ui/time_log", True)
+ s.setValue("ui/reminders", True)
+ s.setValue("ui/locale", "en")
+ s.setValue("ui/font_size", 11)
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ window = MainWindow(themes=themes)
+ qtbot.addWidget(window)
+ window.show()
+
+ # Open today's date
+ today_qdate = QDate.currentDate()
+ window._open_date_in_tab(today_qdate)
+
+ # Set content with invalid time
+ alarm_text = "Do something ⏰ 25:99" # Invalid time
+
+ window.editor.current_date = today_qdate
+ window.editor.setPlainText(alarm_text)
+
+ # Clear any existing timers
+ window._reminder_timers = []
+
+ # Trigger alarm parsing - should not crash
+ window._rebuild_reminders_for_today()
+
+ # No timer should be created for invalid time
+ assert len(window._reminder_timers) == 0
+
+
+def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time):
+ """Test that past alarms are skipped."""
+ from PySide6.QtCore import QTime
+
+ s = get_settings()
+ s.setValue("db/default_db", str(tmp_db_cfg.path))
+ s.setValue("db/key", tmp_db_cfg.key)
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+ s.setValue("ui/move_todos", True)
+ s.setValue("ui/tags", True)
+ s.setValue("ui/time_log", True)
+ s.setValue("ui/reminders", True)
+ s.setValue("ui/locale", "en")
+ s.setValue("ui/font_size", 11)
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ window = MainWindow(themes=themes)
+ qtbot.addWidget(window)
+ window.show()
+
+ # Open today's date
+ today_qdate = QDate.currentDate()
+ window._open_date_in_tab(today_qdate)
+
+ # Set content with past time
+ past_time = QTime.currentTime().addSecs(-3600) # 1 hour ago
+ alarm_text = f"Do something ⏰ {past_time.hour():02d}:{past_time.minute():02d}"
+
+ window.editor.current_date = today_qdate
+ window.editor.setPlainText(alarm_text)
+
+ # Clear any existing timers
+ window._reminder_timers = []
+
+ # Trigger alarm parsing
+ window._rebuild_reminders_for_today()
+
+ # Past alarms should not create timers
+ assert len(window._reminder_timers) == 0
+
+
+def test_parse_today_alarms_no_text(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time):
+ """Test alarm with no text before emoji uses fallback."""
+ from PySide6.QtCore import QTime
+
+ s = get_settings()
+ s.setValue("db/default_db", str(tmp_db_cfg.path))
+ s.setValue("db/key", tmp_db_cfg.key)
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+ s.setValue("ui/move_todos", True)
+ s.setValue("ui/tags", True)
+ s.setValue("ui/time_log", True)
+ s.setValue("ui/reminders", True)
+ s.setValue("ui/locale", "en")
+ s.setValue("ui/font_size", 11)
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ window = MainWindow(themes=themes)
+ qtbot.addWidget(window)
+ window.show()
+
+ # Open today's date
+ today_qdate = QDate.currentDate()
+ window._open_date_in_tab(today_qdate)
+
+ # Set content with alarm but no text
+ future_time = QTime.currentTime().addSecs(3600)
+ alarm_text = f"⏰ {future_time.hour():02d}:{future_time.minute():02d}"
+
+ window.editor.current_date = today_qdate
+ window.editor.setPlainText(alarm_text)
+
+ # Clear any existing timers
+ window._reminder_timers = []
+
+ # Trigger alarm parsing
+ window._rebuild_reminders_for_today()
+
+ # Timer should be created even without text
+ assert len(window._reminder_timers) > 0
+
+
+def test_open_history_with_editor(qtbot, app, tmp_db_cfg, fresh_db):
+ """Test opening history when editor has content."""
+ from unittest.mock import patch
+
+ s = get_settings()
+ s.setValue("db/default_db", str(tmp_db_cfg.path))
+ s.setValue("db/key", tmp_db_cfg.key)
+ s.setValue("ui/idle_minutes", 0)
+ s.setValue("ui/theme", "light")
+ s.setValue("ui/move_todos", True)
+ s.setValue("ui/tags", True)
+ s.setValue("ui/time_log", True)
+ s.setValue("ui/reminders", True)
+ s.setValue("ui/locale", "en")
+ s.setValue("ui/font_size", 11)
+
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ window = MainWindow(themes=themes)
+ qtbot.addWidget(window)
+ window.show()
+
+ # Create some history
+ today = date.today().isoformat()
+ fresh_db.save_new_version(today, "v1", "note1")
+ fresh_db.save_new_version(today, "v2", "note2")
+
+ # Open today's date
+ window._open_date_in_tab(QDate.fromString(today, "yyyy-MM-dd"))
+
+ # Open history
+ with patch("bouquin.history_dialog.HistoryDialog.exec") as mock_exec:
+ mock_exec.return_value = False # User cancels
+ window._open_history()
+
+ # HistoryDialog should have been created and shown
+ mock_exec.assert_called_once()
diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py
index 13244f6..dcacbc5 100644
--- a/tests/test_markdown_editor.py
+++ b/tests/test_markdown_editor.py
@@ -1,20 +1,26 @@
-import pytest
+import base64
-from PySide6.QtCore import Qt, QPoint
+import pytest
+from bouquin.markdown_editor import MarkdownEditor
+from bouquin.markdown_highlighter import MarkdownHighlighter
+from bouquin.theme import Theme, ThemeConfig, ThemeManager
+from PySide6.QtCore import QMimeData, QPoint, Qt, QUrl
from PySide6.QtGui import (
- QImage,
QColor,
+ QFont,
+ QImage,
QKeyEvent,
+ QTextCharFormat,
QTextCursor,
QTextDocument,
- QFont,
- QTextCharFormat,
)
from PySide6.QtWidgets import QApplication, QTextEdit
-from bouquin.markdown_editor import MarkdownEditor
-from bouquin.markdown_highlighter import MarkdownHighlighter
-from bouquin.theme import ThemeManager, ThemeConfig, Theme
+
+def _today():
+ from datetime import date
+
+ return date.today().isoformat()
def text(editor) -> str:
@@ -141,7 +147,7 @@ def test_enter_on_nonempty_list_continues(qtbot, editor):
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
editor.keyPressEvent(ev)
txt = editor.toPlainText()
- assert "\n- " in txt
+ assert "\n\u2022 " in txt
def test_enter_on_empty_list_marks_empty(qtbot, editor):
@@ -154,84 +160,25 @@ def test_enter_on_empty_list_marks_empty(qtbot, editor):
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
editor.keyPressEvent(ev)
- assert editor.toPlainText().startswith("- \n")
+ assert editor.toPlainText().startswith("\u2022 \n")
-def test_triple_backtick_autoexpands(editor, qtbot):
+def test_triple_backtick_triggers_code_dialog_but_no_block_on_empty_code(editor, qtbot):
+ # Start empty
editor.from_markdown("")
press_backtick(qtbot, editor, 2)
- press_backtick(qtbot, editor, 1) # triggers expansion
+ press_backtick(qtbot, editor, 1) # triggers the 3rd-backtick shortcut
qtbot.wait(0)
t = text(editor)
- assert t.count("```") == 2
- assert t.startswith("```\n\n```")
- assert t.endswith("\n")
- # caret is on the blank line inside the block
- assert editor.textCursor().blockNumber() == 1
- assert lines_keep(editor)[1] == ""
+ # The two typed backticks should have been removed
+ assert "`" not in t
-def test_toolbar_inserts_block_on_own_lines(editor, qtbot):
- editor.from_markdown("hello")
- editor.moveCursor(QTextCursor.End)
- editor.apply_code() # > action inserts fenced code block
- qtbot.wait(0)
-
- t = text(editor)
- assert "hello```" not in t # never inline
- assert t.startswith("hello\n```")
- assert t.endswith("```\n")
- # caret inside block (blank line)
- assert editor.textCursor().blockNumber() == 2
- assert lines_keep(editor)[2] == ""
-
-
-def test_toolbar_inside_block_does_not_insert_inline_fences(editor, qtbot):
- editor.from_markdown("")
- editor.apply_code() # create a block (caret now on blank line inside)
- qtbot.wait(0)
-
- pos_before = editor.textCursor().position()
- t_before = text(editor)
-
- editor.apply_code() # pressing > inside should be a no-op
- qtbot.wait(0)
-
- assert text(editor) == t_before
- assert editor.textCursor().position() == pos_before
-
-
-def test_toolbar_on_opening_fence_jumps_inside(editor, qtbot):
- editor.from_markdown("")
- editor.apply_code()
- qtbot.wait(0)
-
- # Go to opening fence (line 0)
- editor.moveCursor(QTextCursor.Start)
- editor.apply_code() # should jump inside the block
- qtbot.wait(0)
-
- assert editor.textCursor().blockNumber() == 1
- assert lines_keep(editor)[1] == ""
-
-
-def test_toolbar_on_closing_fence_jumps_out(editor, qtbot):
- editor.from_markdown("")
- editor.apply_code()
- qtbot.wait(0)
-
- # Go to closing fence line (template: 0 fence, 1 blank, 2 fence, 3 blank-after)
- editor.moveCursor(QTextCursor.End) # blank-after
- editor.moveCursor(QTextCursor.Up) # closing fence
- editor.moveCursor(QTextCursor.StartOfLine)
-
- editor.apply_code() # jump to the line after the fence
- qtbot.wait(0)
-
- # Now on the blank line after the block
- assert editor.textCursor().block().text() == ""
- assert editor.textCursor().block().previous().text().strip() == "```"
+ # With the new dialog-based implementation, and our test stub that accepts
+ # the dialog with empty code, no fenced code block is inserted.
+ assert "```" not in t
+ assert t == ""
def test_down_escapes_from_last_code_line(editor, qtbot):
@@ -515,25 +462,6 @@ def test_apply_italic_and_strike(editor):
assert editor.textCursor().position() == len(editor.toPlainText()) - 2
-def test_apply_code_inline_block_navigation(editor):
- # Selection case -> fenced block around selection
- editor.setPlainText("code")
- c = editor.textCursor()
- c.select(QTextCursor.SelectionType.Document)
- editor.setTextCursor(c)
- editor.apply_code()
- assert "```\ncode\n```\n" in editor.toPlainText()
-
- # No selection, at EOF with no following block -> creates block and extra newline path
- editor.setPlainText("before")
- editor.moveCursor(QTextCursor.MoveOperation.End)
- editor.apply_code()
- t = editor.toPlainText()
- assert t.endswith("before\n```\n\n```\n")
- # Caret should be inside the code block blank line
- assert editor.textCursor().position() == len("before\n") + 4
-
-
def test_insert_image_from_path_invalid_returns(editor_hello, tmp_path):
# Non-existent path should just return (early exit)
bad = tmp_path / "missing.png"
@@ -1464,3 +1392,965 @@ def test_markdown_highlighter_switch_dark_mode(app):
both_valid = light_bg.isValid() and dark_bg.isValid()
assert is_light_lighter or both_valid # At least colors are being set
+
+
+# ============================================================================
+# MarkdownHighlighter Tests - Missing Coverage
+# ============================================================================
+
+
+def test_markdown_highlighter_code_block_detection(qtbot, app):
+ """Test code block detection and highlighting."""
+ theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ doc = QTextDocument()
+ highlighter = MarkdownHighlighter(doc, theme_manager)
+
+ # Set text with code block
+ text = """
+Some text
+```python
+def hello():
+ pass
+```
+More text
+"""
+ doc.setPlainText(text)
+
+ # The highlighter should process the text
+ # Just ensure no crash
+ assert highlighter is not None
+
+
+def test_markdown_highlighter_headers(qtbot, app):
+ """Test header highlighting."""
+ theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ doc = QTextDocument()
+ highlighter = MarkdownHighlighter(doc, theme_manager)
+
+ text = """
+# Header 1
+## Header 2
+### Header 3
+Normal text
+"""
+ doc.setPlainText(text)
+
+ assert highlighter is not None
+
+
+def test_markdown_highlighter_emphasis(qtbot, app):
+ """Test emphasis highlighting."""
+ theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ doc = QTextDocument()
+ highlighter = MarkdownHighlighter(doc, theme_manager)
+
+ text = "**bold** and *italic* and ***both***"
+ doc.setPlainText(text)
+
+ assert highlighter is not None
+
+
+def test_markdown_highlighter_horizontal_rule(qtbot, app):
+ """Test horizontal rule highlighting."""
+ theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ doc = QTextDocument()
+ highlighter = MarkdownHighlighter(doc, theme_manager)
+
+ text = """
+Text above
+---
+Text below
+***
+More text
+___
+End
+"""
+ doc.setPlainText(text)
+
+ assert highlighter is not None
+
+
+def test_markdown_highlighter_complex_document(qtbot, app):
+ """Test highlighting a complex document with mixed elements."""
+ theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ doc = QTextDocument()
+ highlighter = MarkdownHighlighter(doc, theme_manager)
+
+ text = """
+# Main Title
+
+This is a paragraph with **bold** and *italic* text.
+
+## Code Example
+
+Here's some `inline code` and a block:
+
+```python
+def fibonacci(n):
+ if n <= 1:
+ return n
+ return fibonacci(n-1) + fibonacci(n-2)
+```
+
+## Lists
+
+- Item with *emphasis*
+- Another item with **bold**
+- [A link](https://example.com)
+
+> A blockquote with **formatted** text
+> Second line
+
+---
+
+### Final Section
+
+~~Strikethrough~~ and normal text.
+"""
+ doc.setPlainText(text)
+
+ # Should handle complex document
+ assert highlighter is not None
+
+
+def test_markdown_highlighter_empty_document(qtbot, app):
+ """Test highlighting empty document."""
+ theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ doc = QTextDocument()
+ highlighter = MarkdownHighlighter(doc, theme_manager)
+
+ doc.setPlainText("")
+
+ assert highlighter is not None
+
+
+def test_markdown_highlighter_update_on_text_change(qtbot, app):
+ """Test that highlighter updates when text changes."""
+ theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ doc = QTextDocument()
+ highlighter = MarkdownHighlighter(doc, theme_manager)
+
+ doc.setPlainText("Initial text")
+ doc.setPlainText("# Header text")
+ doc.setPlainText("**Bold text**")
+
+ # Should handle updates
+ assert highlighter is not None
+
+
+def test_markdown_highlighter_nested_emphasis(qtbot, app):
+ """Test nested emphasis patterns."""
+ theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ doc = QTextDocument()
+ highlighter = MarkdownHighlighter(doc, theme_manager)
+
+ text = "This has **bold with *italic* inside** and more"
+ doc.setPlainText(text)
+
+ assert highlighter is not None
+
+
+def test_markdown_highlighter_unclosed_code_block(qtbot, app):
+ """Test handling of unclosed code block."""
+ theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ doc = QTextDocument()
+ highlighter = MarkdownHighlighter(doc, theme_manager)
+
+ text = """
+```python
+def hello():
+ print("world")
+"""
+ doc.setPlainText(text)
+
+ # Should handle gracefully
+ assert highlighter is not None
+
+
+def test_markdown_highlighter_special_characters(qtbot, app):
+ """Test handling special characters in markdown."""
+ theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ doc = QTextDocument()
+ highlighter = MarkdownHighlighter(doc, theme_manager)
+
+ text = """
+Special chars: < > & " '
+Escaped: \\* \\_ \\`
+Unicode: 你好 café résumé
+"""
+ doc.setPlainText(text)
+
+ assert highlighter is not None
+
+
+@pytest.mark.parametrize(
+ "markdown_line",
+ [
+ "- [ ] Task", # checkbox
+ "- Task", # bullet
+ "1. Task", # numbered
+ ],
+)
+def test_home_on_list_line_moves_to_text_start(qtbot, editor, markdown_line):
+ """Home on a list line should jump to just after the list marker."""
+ editor.from_markdown(markdown_line)
+
+ # Put caret at end of the line
+ cursor = editor.textCursor()
+ cursor.movePosition(QTextCursor.End)
+ editor.setTextCursor(cursor)
+
+ # Press Home (no modifiers)
+ qtbot.keyPress(editor, Qt.Key_Home)
+ qtbot.wait(0)
+
+ c = editor.textCursor()
+ block = c.block()
+ line = block.text()
+ pos_in_block = c.position() - block.position()
+
+ # The first character of the user text is the 'T' in "Task"
+ logical_start = line.index("Task")
+
+ assert not c.hasSelection()
+ assert pos_in_block == logical_start
+
+
+@pytest.mark.parametrize(
+ "markdown_line",
+ [
+ "- [ ] Task", # checkbox
+ "- Task", # bullet
+ "1. Task", # numbered
+ ],
+)
+def test_shift_home_on_list_line_selects_text_after_marker(
+ qtbot, editor, markdown_line
+):
+ """
+ Shift+Home from the end of a list line should select the text after the marker,
+ not the marker itself.
+ """
+ editor.from_markdown(markdown_line)
+
+ # Put caret at end of the line
+ cursor = editor.textCursor()
+ cursor.movePosition(QTextCursor.End)
+ editor.setTextCursor(cursor)
+
+ # Shift+Home: extend selection back to "logical home"
+ qtbot.keyPress(editor, Qt.Key_Home, Qt.ShiftModifier)
+ qtbot.wait(0)
+
+ c = editor.textCursor()
+ block = c.block()
+ line = block.text()
+ block_start = block.position()
+
+ logical_start = line.index("Task")
+ expected_start = block_start + logical_start
+ expected_end = block_start + len(line)
+
+ assert c.hasSelection()
+ assert c.selectionStart() == expected_start
+ assert c.selectionEnd() == expected_end
+ # Selected text is exactly the user-visible text, not the marker
+ assert c.selectedText() == line[logical_start:]
+
+
+def test_up_from_below_checkbox_moves_to_text_start(qtbot, editor):
+ """
+ Up from the line below a checkbox should land to the right of the checkbox,
+ where the text starts, not to the left of the marker.
+ """
+ editor.from_markdown("- [ ] Task\nSecond line")
+
+ # Put caret somewhere on the second line (end of document is fine)
+ cursor = editor.textCursor()
+ cursor.movePosition(QTextCursor.End)
+ editor.setTextCursor(cursor)
+
+ # Press Up to move to the checkbox line
+ qtbot.keyPress(editor, Qt.Key_Up)
+ qtbot.wait(0)
+
+ c = editor.textCursor()
+ block = c.block()
+ line = block.text()
+ pos_in_block = c.position() - block.position()
+
+ logical_start = line.index("Task")
+ assert pos_in_block >= logical_start
+
+
+def test_backspace_on_empty_checkbox_removes_marker(qtbot, editor):
+ """
+ When a checkbox line has no text after the marker, Backspace at/after the
+ text position should delete the marker itself, leaving a plain empty line.
+ """
+ editor.from_markdown("- [ ] ")
+
+ # Put caret at end of the checkbox line (after the marker)
+ cursor = editor.textCursor()
+ cursor.movePosition(QTextCursor.End)
+ editor.setTextCursor(cursor)
+
+ qtbot.keyPress(editor, Qt.Key_Backspace)
+ qtbot.wait(0)
+
+ first_block = editor.document().firstBlock()
+ # Marker should be gone
+ assert first_block.text() == ""
+ assert editor._CHECK_UNCHECKED_DISPLAY not in editor.toPlainText()
+
+
+def test_render_images_with_corrupted_data(qtbot, app):
+ """Test rendering images with corrupted data that creates null QImage"""
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ editor = MarkdownEditor(theme_manager=themes)
+ qtbot.addWidget(editor)
+ editor.show()
+
+ # Create some binary data that will decode but not form a valid image
+ corrupted_data = base64.b64encode(b"not an image file").decode("utf-8")
+ markdown = f""
+
+ editor.from_markdown(markdown)
+ qtbot.wait(50)
+
+ # Should still work without crashing
+ text = editor.to_markdown()
+ assert len(text) >= 0
+
+
+def test_editor_with_code_blocks(qtbot, app):
+ """Test editor with code blocks"""
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ editor = MarkdownEditor(theme_manager=themes)
+ qtbot.addWidget(editor)
+ editor.show()
+
+ code_markdown = """
+Some text
+
+```python
+def hello():
+ print("world")
+```
+
+More text
+"""
+ editor.from_markdown(code_markdown)
+ qtbot.wait(50)
+
+ result = editor.to_markdown()
+ assert "def hello" in result or "python" in result
+
+
+def test_editor_undo_redo(qtbot, app):
+ """Test undo/redo functionality"""
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ editor = MarkdownEditor(theme_manager=themes)
+ qtbot.addWidget(editor)
+ editor.show()
+
+ # Type some text
+ editor.from_markdown("Initial text")
+ qtbot.wait(50)
+
+ # Add more text
+ editor.insertPlainText(" additional")
+ qtbot.wait(50)
+
+ # Undo
+ editor.undo()
+ qtbot.wait(50)
+
+ # Redo
+ editor.redo()
+ qtbot.wait(50)
+
+ assert len(editor.to_markdown()) > 0
+
+
+def test_editor_cut_copy_paste(qtbot, app):
+ """Test cut/copy/paste operations"""
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ editor = MarkdownEditor(theme_manager=themes)
+ qtbot.addWidget(editor)
+ editor.show()
+
+ editor.from_markdown("Test content for copy")
+ qtbot.wait(50)
+
+ # Select all
+ editor.selectAll()
+
+ # Copy
+ editor.copy()
+ qtbot.wait(50)
+
+ # Move to end and paste
+ cursor = editor.textCursor()
+ cursor.movePosition(QTextCursor.End)
+ editor.setTextCursor(cursor)
+
+ editor.paste()
+ qtbot.wait(50)
+
+ # Should have content twice (or clipboard might be empty in test env)
+ assert len(editor.to_markdown()) > 0
+
+
+def test_editor_with_blockquotes(qtbot, app):
+ """Test editor with blockquotes"""
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ editor = MarkdownEditor(theme_manager=themes)
+ qtbot.addWidget(editor)
+ editor.show()
+
+ quote_markdown = """
+Normal text
+
+> This is a quote
+> With multiple lines
+
+More normal text
+"""
+ editor.from_markdown(quote_markdown)
+ qtbot.wait(50)
+
+ result = editor.to_markdown()
+ assert ">" in result or "quote" in result
+
+
+def test_editor_with_horizontal_rules(qtbot, app):
+ """Test editor with horizontal rules"""
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ editor = MarkdownEditor(theme_manager=themes)
+ qtbot.addWidget(editor)
+ editor.show()
+
+ hr_markdown = """
+Section 1
+
+---
+
+Section 2
+"""
+ editor.from_markdown(hr_markdown)
+ qtbot.wait(50)
+
+ result = editor.to_markdown()
+ assert "Section" in result
+
+
+def test_editor_with_mixed_content(qtbot, app):
+ """Test editor with mixed markdown content"""
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ editor = MarkdownEditor(theme_manager=themes)
+ qtbot.addWidget(editor)
+ editor.show()
+
+ mixed_markdown = """
+# Heading
+
+This is **bold** and *italic* text.
+
+- [ ] Todo item
+- [x] Completed item
+
+```python
+code()
+```
+
+[Link](https://example.com)
+
+> Quote
+
+| Table | Header |
+|-------|--------|
+| A | B |
+"""
+ editor.from_markdown(mixed_markdown)
+ qtbot.wait(50)
+
+ result = editor.to_markdown()
+ # Should contain various markdown elements
+ assert len(result) > 50
+
+
+def test_editor_insert_text_at_cursor(qtbot, app):
+ """Test inserting text at cursor position"""
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ editor = MarkdownEditor(theme_manager=themes)
+ qtbot.addWidget(editor)
+ editor.show()
+
+ editor.from_markdown("Start Middle End")
+ qtbot.wait(50)
+
+ # Move cursor to middle
+ cursor = editor.textCursor()
+ cursor.setPosition(6)
+ editor.setTextCursor(cursor)
+
+ # Insert text
+ editor.insertPlainText("INSERTED ")
+ qtbot.wait(50)
+
+ result = editor.to_markdown()
+ assert "INSERTED" in result
+
+
+def test_editor_delete_operations(qtbot, app):
+ """Test delete operations"""
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ editor = MarkdownEditor(theme_manager=themes)
+ qtbot.addWidget(editor)
+ editor.show()
+
+ editor.from_markdown("Text to delete")
+ qtbot.wait(50)
+
+ # Select some text and delete
+ cursor = editor.textCursor()
+ cursor.setPosition(0)
+ cursor.setPosition(4, QTextCursor.KeepAnchor)
+ editor.setTextCursor(cursor)
+
+ cursor.removeSelectedText()
+ qtbot.wait(50)
+
+ result = editor.to_markdown()
+ assert "Text" not in result or len(result) < 15
+
+
+def test_markdown_highlighter_dark_theme(qtbot, app):
+ """Test markdown highlighter with dark theme"""
+ # Create theme manager with dark theme
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK))
+
+ # Create a text document
+ doc = QTextDocument()
+
+ # Create highlighter with dark theme
+ highlighter = MarkdownHighlighter(doc, themes)
+
+ # Set some markdown text
+ doc.setPlainText("# Heading\n\nSome **bold** text\n\n```python\ncode\n```")
+
+ # The highlighter should work with dark theme
+ assert highlighter is not None
+ assert highlighter.code_block_format is not None
+
+
+def test_markdown_highlighter_light_theme(qtbot, app):
+ """Test markdown highlighter with light theme"""
+ # Create theme manager with light theme
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+
+ # Create a text document
+ doc = QTextDocument()
+
+ # Create highlighter with light theme
+ highlighter = MarkdownHighlighter(doc, themes)
+
+ # Set some markdown text
+ doc.setPlainText("# Heading\n\nSome **bold** text")
+
+ # The highlighter should work with light theme
+ assert highlighter is not None
+ assert highlighter.code_block_format is not None
+
+
+def test_markdown_highlighter_system_dark_theme(qtbot, app, monkeypatch):
+ """Test markdown highlighter with system dark theme"""
+ # Create theme manager with system theme
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.SYSTEM))
+
+ # Mock the system to be dark
+ monkeypatch.setattr(themes, "_is_system_dark", True)
+
+ # Create a text document
+ doc = QTextDocument()
+
+ # Create highlighter
+ highlighter = MarkdownHighlighter(doc, themes)
+
+ # Set some markdown text
+ doc.setPlainText("# Dark Theme Heading\n\n**Bold text**")
+
+ # The highlighter should use dark theme colors
+ assert highlighter is not None
+
+
+def test_markdown_highlighter_with_headings(qtbot, app):
+ """Test highlighting various heading levels"""
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ doc = QTextDocument()
+ highlighter = MarkdownHighlighter(doc, themes)
+
+ markdown = """
+# H1 Heading
+## H2 Heading
+### H3 Heading
+#### H4 Heading
+##### H5 Heading
+###### H6 Heading
+"""
+ doc.setPlainText(markdown)
+
+ # Should highlight all headings
+ assert highlighter.h1_format is not None
+ assert highlighter.h2_format is not None
+
+
+def test_markdown_highlighter_with_emphasis(qtbot, app):
+ """Test highlighting bold and italic"""
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ doc = QTextDocument()
+ highlighter = MarkdownHighlighter(doc, themes)
+
+ markdown = """
+**Bold text**
+*Italic text*
+***Bold and italic***
+__Also bold__
+_Also italic_
+"""
+ doc.setPlainText(markdown)
+
+ # Should have emphasis formats
+ assert highlighter is not None
+
+
+def test_markdown_highlighter_with_code(qtbot, app):
+ """Test highlighting inline code and code blocks"""
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ doc = QTextDocument()
+ highlighter = MarkdownHighlighter(doc, themes)
+
+ markdown = """
+Inline `code` here.
+
+```python
+def hello():
+ print("world")
+```
+
+More text.
+"""
+ doc.setPlainText(markdown)
+
+ # Should highlight code
+ assert highlighter.code_block_format is not None
+
+
+def test_markdown_highlighter_with_links(qtbot, app):
+ """Test highlighting links"""
+ themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
+ doc = QTextDocument()
+ highlighter = MarkdownHighlighter(doc, themes)
+
+ markdown = """
+[Link text](https://example.com)
+