remove time graph visualiser. More tests. Other fixes
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled

This commit is contained in:
Miguel Jacq 2025-11-19 15:33:31 +11:00
parent 0b3249c7ef
commit 985541a1d8
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
18 changed files with 4087 additions and 971 deletions

View file

@ -2,7 +2,6 @@
* Remove screenshot tool * Remove screenshot tool
* Improve width of bug report dialog * Improve width of bug report dialog
* Add Tag relationship visualisation tool
* Improve size of checkboxes * Improve size of checkboxes
* Convert bullet - to actual unicode bullets * Convert bullet - to actual unicode bullets
* Add alarm option to set reminders * Add alarm option to set reminders

View file

@ -3,7 +3,11 @@
## Introduction ## Introduction
Bouquin ("Book-ahn") is a simple, opinionated notebook application written in Python, PyQt and SQLCipher. Bouquin ("Book-ahn") is a notebook and planner application written in Python, PyQt and SQLCipher.
It is designed to treat each day as its own 'page', complete with Markdown rendering, tagging,
search, reminders and time logging for those of us who need to keep track of not just TODOs, but
also how long we spent on them.
It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement
for SQLite3. This means that the underlying database for the notebook is encrypted at rest. for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
@ -23,27 +27,22 @@ report from within the app.
![Screenshot of Bouquin History Preview pane](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_preview.png) ![Screenshot of Bouquin History Preview pane](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_preview.png)
![Screenshot of Bouquin History Diff pane](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_diff.png) ![Screenshot of Bouquin History Diff pane](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_diff.png)
### Tag relationship visualiser ## Some of the features
![Screenshot of Tag Relationship Visualiser](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/bouquin_tag_relationship_graph.png)
## Features
* Data is encrypted at rest * Data is encrypted at rest
* Encryption key is prompted for and never stored, unless user chooses to via Settings * Encryption key is prompted for and never stored, unless user chooses to via Settings
* Every 'page' is linked to the calendar day
* All changes are version controlled, with ability to view/diff versions and revert * All changes are version controlled, with ability to view/diff versions and revert
* Text is Markdown with basic styling * Automatic rendering of basic Markdown syntax
* Tabs are supported - right-click on a date from the calendar to open it in a new tab. * Tabs are supported - right-click on a date from the calendar to open it in a new tab.
* Images are supported * Images are supported
* Search all pages, or find text on page (Ctrl+F) * Search all pages, or find text on current page
* Add tags to pages, find pages by tag in the Tag Browser, and customise tag names and colours * Add and manage tags
* Automatic periodic saving (or explicitly save) * Automatic periodic saving (or explicitly save)
* Transparent integrity checking of the database when it opens
* Automatic locking of the app after a period of inactivity (default 15 min) * Automatic locking of the app after a period of inactivity (default 15 min)
* Rekey the database (change the password) * Rekey the database (change the password)
* Export the database to json, html, csv, markdown or .sql (for sqlite3) * Export the database to json, html, csv, markdown or .sql (for sqlite3)
* Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin) * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
* Dark and light themes * Dark and light theme support
* Automatically generate checkboxes when typing 'TODO' * Automatically generate checkboxes when typing 'TODO'
* It is possible to automatically move unchecked checkboxes from yesterday to today, on startup * It is possible to automatically move unchecked checkboxes from yesterday to today, on startup
* English, French and Italian locales provided * English, French and Italian locales provided

View file

@ -790,49 +790,6 @@ class DBManager:
revisions_by_date, revisions_by_date,
) )
def get_tag_cooccurrences(self):
"""
Compute tagtag co-occurrence across pages.
Returns:
tags_by_id: dict[int, TagRow] # id -> (id, name, color)
edges: list[(int, int, int)] # (tag_id1, tag_id2, page_count)
tag_page_counts: dict[int, int] # tag_id -> number of pages it appears on
"""
cur = self.conn.cursor()
# 1) All tags (reuse existing helper)
all_tags: list[TagRow] = self.list_tags()
tags_by_id: dict[int, TagRow] = {t[0]: t for t in all_tags}
# 2) How many pages each tag appears on (for node sizing)
rows = cur.execute(
"""
SELECT tag_id, COUNT(DISTINCT page_date) AS c
FROM page_tags
GROUP BY tag_id;
"""
).fetchall()
tag_page_counts = {r["tag_id"]: r["c"] for r in rows}
# 3) Co-occurrence of tag pairs on the same page
rows = cur.execute(
"""
SELECT
pt1.tag_id AS tag1,
pt2.tag_id AS tag2,
COUNT(DISTINCT pt1.page_date) AS c
FROM page_tags AS pt1
JOIN page_tags AS pt2
ON pt1.page_date = pt2.page_date
AND pt1.tag_id < pt2.tag_id
GROUP BY pt1.tag_id, pt2.tag_id;
""",
).fetchall()
edges = [(r["tag1"], r["tag2"], r["c"]) for r in rows]
return tags_by_id, edges, tag_page_counts
# -------- Time logging: projects & activities --------------------- # -------- Time logging: projects & activities ---------------------
def list_projects(self) -> list[ProjectRow]: def list_projects(self) -> list[ProjectRow]:
@ -1037,7 +994,7 @@ class DBManager:
AND t.page_date BETWEEN ? AND ? AND t.page_date BETWEEN ? AND ?
GROUP BY bucket, activity_name GROUP BY bucket, activity_name
ORDER BY bucket, LOWER(activity_name); ORDER BY bucket, LOWER(activity_name);
""", # nosec B608: bucket_expr comes from a fixed internal list """, # nosec
(project_id, start_date_iso, end_date_iso), (project_id, start_date_iso, end_date_iso),
).fetchall() ).fetchall()

View file

@ -135,7 +135,6 @@
"delete_tag": "Delete tag", "delete_tag": "Delete tag",
"delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages.", "delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages.",
"tag_already_exists_with_that_name": "A tag already exists with that name", "tag_already_exists_with_that_name": "A tag already exists with that name",
"tag_graph": "Tag relationship graph",
"statistics": "Statistics", "statistics": "Statistics",
"main_window_statistics_accessible_flag": "Stat&istics", "main_window_statistics_accessible_flag": "Stat&istics",
"stats_pages_with_content": "Pages with content (current version)", "stats_pages_with_content": "Pages with content (current version)",

View file

@ -14,7 +14,6 @@ from PySide6.QtWidgets import (
) )
from .db import DBManager from .db import DBManager
from .tag_graph_dialog import TagGraphDialog
from . import strings from . import strings
from sqlcipher3.dbapi2 import IntegrityError from sqlcipher3.dbapi2 import IntegrityError
@ -72,10 +71,6 @@ class TagBrowserDialog(QDialog):
self.delete_btn.setEnabled(False) self.delete_btn.setEnabled(False)
btn_row.addWidget(self.delete_btn) btn_row.addWidget(self.delete_btn)
self.tag_graph_btn = QPushButton(strings._("tag_graph"))
self.tag_graph_btn.clicked.connect(self._open_tag_graph)
btn_row.addWidget(self.tag_graph_btn)
btn_row.addStretch(1) btn_row.addStretch(1)
layout.addLayout(btn_row) layout.addLayout(btn_row)
@ -256,9 +251,3 @@ class TagBrowserDialog(QDialog):
self._db.delete_tag(tag_id) self._db.delete_tag(tag_id)
self._populate(None) self._populate(None)
self.tagsModified.emit() self.tagsModified.emit()
# ------------ Tag graph handler --------------- #
def _open_tag_graph(self):
dlg = TagGraphDialog(self._db, self)
dlg.resize(800, 600)
dlg.exec()

View file

@ -1,309 +0,0 @@
import networkx as nx
import numpy as np
import pyqtgraph as pg
from pyqtgraph.Qt import QtCore
from PySide6.QtWidgets import QDialog, QVBoxLayout, QToolTip
from PySide6.QtGui import QFont, QCursor, QColor
from .db import DBManager
from . import strings
class DraggableGraphItem(pg.GraphItem):
"""GraphItem where individual nodes can be dragged with the left mouse button,
and hover events can be reported back to the owning dialog.
"""
def __init__(self, on_position_changed=None, on_hover=None, **kwds):
# Our own fields MUST be set before super().__init__ because
# GraphItem.__init__ will call self.setData(...)
self._drag_index = None
self._drag_offset = None
self._on_position_changed = on_position_changed
self._on_hover = on_hover
self.pos = None
self._data_kwargs = {} # cache of last setData kwargs
super().__init__(**kwds)
self.setAcceptHoverEvents(True)
def setData(self, **kwds):
"""Cache kwargs so we don't lose size/adj/brush on drag."""
if "pos" in kwds:
self.pos = kwds["pos"]
self._data_kwargs.update(kwds)
super().setData(**self._data_kwargs)
def mouseDragEvent(self, ev):
# --- start of drag ---
if ev.isStart():
if ev.button() != QtCore.Qt.MouseButton.LeftButton:
ev.ignore()
return
pos = ev.buttonDownPos()
pts = self.scatter.pointsAt(pos)
# pointsAt may return an empty list/array
if pts is None or len(pts) == 0:
ev.ignore()
return
spot = pts[0]
self._drag_index = spot.index()
node_pos = np.array(self.pos[self._drag_index], dtype=float)
if hasattr(pos, "x"):
mouse = np.array([pos.x(), pos.y()], dtype=float)
else:
mouse = np.array(pos, dtype=float)
self._drag_offset = node_pos - mouse
ev.accept()
return
# --- end of drag ---
if ev.isFinish():
self._drag_index = None
self._drag_offset = None
ev.accept()
return
# --- drag in progress ---
if self._drag_index is None:
ev.ignore()
return
pos = ev.pos()
if hasattr(pos, "x"):
mouse = np.array([pos.x(), pos.y()], dtype=float)
else:
mouse = np.array(pos, dtype=float)
new_pos = mouse + self._drag_offset
self.pos[self._drag_index] = new_pos # mutate in-place
# Repaint graph, preserving all the other kwargs (size, adj, colours, ...)
self.setData(pos=self.pos)
if self._on_position_changed is not None:
self._on_position_changed(self.pos)
ev.accept()
def hoverEvent(self, ev):
"""Report which node (if any) is under the mouse while hovering."""
# Leaving the item entirely
if ev.isExit():
if self._on_hover is not None:
self._on_hover(None, ev)
return
pos = ev.pos()
pts = self.scatter.pointsAt(pos)
if pts is None or len(pts) == 0:
if self._on_hover is not None:
self._on_hover(None, ev)
return
idx = pts[0].index()
if self._on_hover is not None:
self._on_hover(idx, ev)
class TagGraphDialog(QDialog):
def __init__(self, db: DBManager, parent=None):
super().__init__(parent)
self.setWindowTitle(strings._("tag_graph"))
layout = QVBoxLayout(self)
self.view = pg.GraphicsLayoutWidget()
layout.addWidget(self.view)
self.plot = self.view.addPlot()
self.plot.hideAxis("bottom")
self.plot.hideAxis("left")
# Dark-ish background, Grafana / neon style
self.view.setBackground("#050816")
self.plot.setMouseEnabled(x=True, y=True)
self.plot.getViewBox().setDefaultPadding(0.15)
# State for tags / edges / labels / halo
self._label_items = []
self._tag_ids = []
self._tag_names = {}
self._tag_page_counts = {}
self._halo_sizes = []
self._halo_brushes = []
self.graph_item = DraggableGraphItem(
on_position_changed=self._on_positions_changed,
on_hover=self._on_hover_index,
)
self.plot.addItem(self.graph_item)
# Separate scatter for "halo" glow behind nodes
self._halo_item = pg.ScatterPlotItem(pxMode=True)
self._halo_item.setZValue(-1) # draw behind nodes/labels
self.plot.addItem(self._halo_item)
self._populate_graph(db)
def _populate_graph(self, db: DBManager):
tags_by_id, edges, tag_page_counts = db.get_tag_cooccurrences()
if not tags_by_id:
return
# Map tag_id -> index
tag_ids = list(tags_by_id.keys())
self._tag_ids = tag_ids
self._tag_page_counts = dict(tag_page_counts)
self._tag_names = {tid: tags_by_id[tid][1] for tid in tag_ids}
idx_of = {tid: i for i, tid in enumerate(tag_ids)}
N = len(tag_ids)
# ---- Layout: prefer a weighted spring layout via networkx (topic islands)
if edges:
G = nx.Graph()
for tid in tag_ids:
G.add_node(tid)
for t1, t2, w in edges:
G.add_edge(t1, t2, weight=w)
pos_dict = nx.spring_layout(G, weight="weight", k=1.2, iterations=80)
pos = np.array([pos_dict[tid] for tid in tag_ids], dtype=float)
else:
# Fallback: random-ish blob
pos = np.random.normal(size=(N, 2))
# Adjacency (edges)
adj = np.array([[idx_of[t1], idx_of[t2]] for t1, t2, _ in edges], dtype=int)
# Node sizes: proportional to how often tag is used
max_pages = max(tag_page_counts.values() or [1])
sizes = np.array(
[10 + 20 * (tag_page_counts.get(tid, 0) / max_pages) for tid in tag_ids],
dtype=float,
)
# ---- Neon-style nodes ----
# Inner fill: dark; outline: tag hex colour
node_brushes = []
node_pens = []
dark_fill = (5, 8, 22, 230) # almost background, slightly lighter
# For halo
halo_sizes = []
halo_brushes = []
for i, tid in enumerate(tag_ids):
_id, name, color = tags_by_id[tid]
# node interior (dark) + bright outline
node_brushes.append(pg.mkBrush(dark_fill))
node_pens.append(pg.mkPen(color, width=2.5))
# halo: semi-transparent version of DB colour, larger than node
qcol = QColor(color)
qcol.setAlpha(90)
halo_brushes.append(pg.mkBrush(qcol))
halo_sizes.append(sizes[i] * 1.8)
self._halo_sizes = halo_sizes
self._halo_brushes = halo_brushes
# ---- Edges: softer neon-ish lines with opacity / width based on co-occurrence ----
if edges:
weights = np.array([w for _, _, w in edges], dtype=float)
max_w = weights.max() if weights.size else 1.0
weight_factors = (weights / max_w).clip(0.0, 1.0)
# bright cyan-ish neon
base_color = (56, 189, 248) # tailwind-ish cyan-400
edge_pens = []
for wf in weight_factors:
alpha = int(40 + 160 * wf) # 40200
width = 0.7 + 2.3 * wf # 0.73.0
edge_pens.append(pg.mkPen((*base_color, alpha), width=width))
else:
edge_pens = None
# Assign data to GraphItem (this will set self.graph_item.pos)
self.graph_item.setData(
pos=pos,
adj=adj,
size=sizes,
symbolBrush=node_brushes,
symbolPen=node_pens,
edgePen=edge_pens,
pxMode=True,
)
# ---- Neon halo layer (behind nodes) ----
xs = [p[0] for p in pos]
ys = [p[1] for p in pos]
self._halo_item.setData(
x=xs,
y=ys,
size=self._halo_sizes,
brush=self._halo_brushes,
pen=None,
)
# ---- Add text labels for each tag ----
self._label_items = [] # reset
font = QFont()
font.setPointSize(8)
for i, tid in enumerate(tag_ids):
_id, name, color = tags_by_id[tid]
label = pg.TextItem(text=name, color=color, anchor=(0.5, 0.5))
label.setFont(font)
self.plot.addItem(label)
self._label_items.append(label)
# Initial placement of labels
self._on_positions_changed(pos)
def _on_positions_changed(self, pos):
"""Called by DraggableGraphItem whenever node positions change."""
if not self._label_items:
return
# Update labels
for i, label in enumerate(self._label_items):
label.setPos(float(pos[i, 0]), float(pos[i, 1]) + 0.15)
# Update halo positions to match nodes
if self._halo_sizes and self._halo_brushes:
xs = [p[0] for p in pos]
ys = [p[1] for p in pos]
self._halo_item.setData(
x=xs,
y=ys,
size=self._halo_sizes,
brush=self._halo_brushes,
pen=None,
)
def _on_hover_index(self, index, ev):
"""Show '<tag>: N pages' when hovering a node."""
if index is None or not self._tag_ids:
QToolTip.hideText()
return
tag_id = self._tag_ids[index]
name = self._tag_names.get(tag_id, "")
count = self._tag_page_counts.get(tag_id, 0)
text = f"{name}: {count} page{'s' if count != 1 else ''}"
QToolTip.showText(QCursor.pos(), text, self)

View file

@ -161,7 +161,7 @@ class TimeLogWidget(QFrame):
# Always refresh summary + header totals # Always refresh summary + header totals
self._reload_summary() self._reload_summary()
if self.toggle_btn.isChecked(): if not self.toggle_btn.isChecked():
self.summary_label.setText(strings._("time_log_collapsed_hint")) self.summary_label.setText(strings._("time_log_collapsed_hint"))

398
poetry.lock generated
View file

@ -2,13 +2,13 @@
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.10.5" version = "2025.11.12"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"},
{file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"},
] ]
[[package]] [[package]]
@ -146,133 +146,141 @@ files = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.10.7" version = "7.12.0"
description = "Code coverage measurement for Python" description = "Code coverage measurement for Python"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.10"
files = [ files = [
{file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, {file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"},
{file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, {file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"},
{file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, {file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"},
{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.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"},
{file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, {file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"},
{file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, {file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"},
{file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"},
{file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, {file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"},
{file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, {file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"},
{file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, {file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"},
{file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, {file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"},
{file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, {file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"},
{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.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"},
{file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, {file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"},
{file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, {file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"},
{file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"},
{file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, {file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"},
{file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, {file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"},
{file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, {file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"},
{file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, {file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"},
{file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, {file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"},
{file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, {file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"},
{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.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"},
{file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, {file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"},
{file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, {file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"},
{file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"},
{file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, {file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"},
{file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, {file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"},
{file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, {file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"},
{file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, {file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"},
{file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, {file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"},
{file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, {file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"},
{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.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"},
{file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, {file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"},
{file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, {file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"},
{file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"},
{file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, {file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"},
{file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, {file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"},
{file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, {file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"},
{file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, {file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"},
{file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, {file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, {file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"},
{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.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, {file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"},
{file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, {file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"},
{file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"},
{file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, {file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"},
{file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, {file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"},
{file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, {file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"},
{file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, {file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"},
{file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, {file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"},
{file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, {file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"},
{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.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"},
{file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, {file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"},
{file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, {file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"},
{file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"},
{file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, {file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"},
{file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, {file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"},
{file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, {file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"},
{file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, {file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"},
{file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, {file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, {file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"},
{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.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, {file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"},
{file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, {file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"},
{file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"},
{file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, {file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"},
{file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, {file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"},
{file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, {file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"},
{file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, {file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"},
{file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, {file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"},
{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"},
] ]
[package.dependencies]
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
[package.extras] [package.extras]
toml = ["tomli"] toml = ["tomli"]
[[package]] [[package]]
name = "desktop-entry-lib" name = "desktop-entry-lib"
version = "3.2" version = "5.0"
description = "A library for working with .desktop files" description = "A library for working with .desktop files"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.10"
files = [ files = [
{file = "desktop-entry-lib-3.2.tar.gz", hash = "sha256:12189249f86dde52d055ac28897ad1ed14ef965407a50fb3ad4ac6cb1a7e8cde"}, {file = "desktop_entry_lib-5.0-py3-none-any.whl", hash = "sha256:e60a0c2c5e42492dbe5378e596b1de87d1b1c4dc74d1f41998a164ee27a1226f"},
{file = "desktop_entry_lib-3.2-py3-none-any.whl", hash = "sha256:600748b2aab2cafbb3ebc08b5c1ded024d66e0756868b6d26b5aff44d336c4b5"}, {file = "desktop_entry_lib-5.0.tar.gz", hash = "sha256:9a621bac1819fe21021356e41fec0ac096ed56e6eb5dcfe0639cd8654914b864"},
] ]
[package.extras] [package.extras]
test = ["pyfakefs", "pytest", "pytest-cov"] xdg-desktop-portal = ["jeepney"]
[[package]]
name = "exceptiongroup"
version = "1.3.0"
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"},
]
[package.dependencies]
typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
[package.extras]
test = ["pytest (>=6)"]
[[package]] [[package]]
name = "idna" name = "idna"
@ -290,97 +298,13 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.1.0" version = "2.3.0"
description = "brain-dead simple config-ini parsing" description = "brain-dead simple config-ini parsing"
optional = false optional = false
python-versions = ">=3.8"
files = [
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
[[package]]
name = "networkx"
version = "3.5"
description = "Python package for creating and manipulating graphs and networks"
optional = false
python-versions = ">=3.11"
files = [
{file = "networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec"},
{file = "networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037"},
]
[package.extras]
default = ["matplotlib (>=3.8)", "numpy (>=1.25)", "pandas (>=2.0)", "scipy (>=1.11.2)"]
developer = ["mypy (>=1.15)", "pre-commit (>=4.1)"]
doc = ["intersphinx-registry", "myst-nb (>=1.1)", "numpydoc (>=1.8.0)", "pillow (>=10)", "pydata-sphinx-theme (>=0.16)", "sphinx (>=8.0)", "sphinx-gallery (>=0.18)", "texext (>=0.6.7)"]
example = ["cairocffi (>=1.7)", "contextily (>=1.6)", "igraph (>=0.11)", "momepy (>=0.7.2)", "osmnx (>=2.0.0)", "scikit-learn (>=1.5)", "seaborn (>=0.13)"]
extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.10)"]
test = ["pytest (>=7.2)", "pytest-cov (>=4.0)", "pytest-xdist (>=3.0)"]
test-extras = ["pytest-mpl", "pytest-randomly"]
[[package]]
name = "numpy"
version = "2.2.6"
description = "Fundamental package for array computing in Python"
optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
{file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"}, {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
{file = "numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163"},
{file = "numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf"},
{file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83"},
{file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915"},
{file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680"},
{file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289"},
{file = "numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d"},
{file = "numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3"},
{file = "numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae"},
{file = "numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a"},
{file = "numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42"},
{file = "numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491"},
{file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a"},
{file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf"},
{file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1"},
{file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab"},
{file = "numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47"},
{file = "numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303"},
{file = "numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff"},
{file = "numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c"},
{file = "numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3"},
{file = "numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282"},
{file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87"},
{file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249"},
{file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49"},
{file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de"},
{file = "numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4"},
{file = "numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2"},
{file = "numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84"},
{file = "numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b"},
{file = "numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d"},
{file = "numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566"},
{file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f"},
{file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f"},
{file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868"},
{file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d"},
{file = "numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd"},
{file = "numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c"},
{file = "numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6"},
{file = "numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda"},
{file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40"},
{file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8"},
{file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f"},
{file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa"},
{file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571"},
{file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1"},
{file = "numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff"},
{file = "numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06"},
{file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d"},
{file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db"},
{file = "numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543"},
{file = "numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00"},
{file = "numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd"},
] ]
[[package]] [[package]]
@ -437,20 +361,7 @@ files = [
[package.dependencies] [package.dependencies]
desktop-entry-lib = "*" desktop-entry-lib = "*"
requests = "*" requests = "*"
tomli = {version = "*", markers = "python_version < \"3.11\""}
[[package]]
name = "pyqtgraph"
version = "0.14.0"
description = "Scientific Graphics and GUI Library for Python"
optional = false
python-versions = ">=3.10"
files = [
{file = "pyqtgraph-0.14.0-py3-none-any.whl", hash = "sha256:7abb7c3e17362add64f8711b474dffac5e7b0e9245abdf992e9a44119b7aa4f5"},
]
[package.dependencies]
colorama = "*"
numpy = ">=1.25.0"
[[package]] [[package]]
name = "pyside6" name = "pyside6"
@ -519,10 +430,12 @@ files = [
[package.dependencies] [package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""}
iniconfig = ">=1" iniconfig = ">=1"
packaging = ">=20" packaging = ">=20"
pluggy = ">=1.5,<2" pluggy = ">=1.5,<2"
pygments = ">=2.7.2" pygments = ">=2.7.2"
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras] [package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
@ -749,6 +662,57 @@ files = [
{file = "sqlcipher3_wheels-0.5.5.post0.tar.gz", hash = "sha256:2c291ba05fa3e57c9b4d407d2751aa69266b5372468e7402daaa312b251aca7f"}, {file = "sqlcipher3_wheels-0.5.5.post0.tar.gz", hash = "sha256:2c291ba05fa3e57c9b4d407d2751aa69266b5372468e7402daaa312b251aca7f"},
] ]
[[package]]
name = "tomli"
version = "2.3.0"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
files = [
{file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"},
{file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"},
{file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"},
{file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"},
{file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"},
{file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"},
{file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"},
{file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"},
{file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"},
{file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"},
{file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"},
{file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"},
{file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"},
{file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"},
{file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"},
{file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"},
{file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"},
{file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"},
{file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"},
{file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"},
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"
@ -779,5 +743,5 @@ zstd = ["zstandard (>=0.18.0)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.11,<3.14" python-versions = ">=3.10,<3.14"
content-hash = "02e508f6d5fc3843084d48a8e495af69555ce6163e281d62b2d3242378e68274" content-hash = "d5fd8ea759b6bd3f23336930bdce9241659256ed918ec31746787cc86e817235"

View file

@ -10,12 +10,10 @@ packages = [{ include = "bouquin" }]
include = ["bouquin/locales/*.json"] include = ["bouquin/locales/*.json"]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.11,<3.14" python = ">=3.10,<3.14"
pyside6 = ">=6.8.1,<7.0.0" pyside6 = ">=6.8.1,<7.0.0"
sqlcipher3-wheels = "^0.5.5.post0" sqlcipher3-wheels = "^0.5.5.post0"
requests = "^2.32.5" requests = "^2.32.5"
pyqtgraph = "^0.14.0"
networkx = "^3.5"
[tool.poetry.scripts] [tool.poetry.scripts]
bouquin = "bouquin.__main__:main" bouquin = "bouquin.__main__:main"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

View file

@ -1,5 +1,8 @@
import bouquin.bug_report_dialog as bugmod import bouquin.bug_report_dialog as bugmod
from bouquin.bug_report_dialog import BugReportDialog from bouquin.bug_report_dialog import BugReportDialog
from bouquin import strings
from PySide6.QtWidgets import QMessageBox
from PySide6.QtGui import QTextCursor
def test_bug_report_truncates_text_to_max_chars(qtbot): def test_bug_report_truncates_text_to_max_chars(qtbot):
@ -193,3 +196,129 @@ def test_bug_report_send_failure_non_201_shows_critical_and_not_accepted(
# Dialog should NOT be accepted on failure # Dialog should NOT be accepted on failure
assert accepted.get("called") is not True assert accepted.get("called") is not True
def test_bug_report_dialog_text_limit_clamps_cursor(qtbot):
"""Test that cursor position is clamped when text exceeds limit."""
strings.load_strings("en")
dialog = BugReportDialog()
qtbot.addWidget(dialog)
dialog.show()
# Set text that exceeds MAX_CHARS
max_chars = dialog.MAX_CHARS
long_text = "A" * (max_chars + 100)
# Set text and move cursor to end
dialog.text_edit.setPlainText(long_text)
dialog.text_edit.moveCursor(QTextCursor.MoveOperation.End)
# Text should be truncated
assert len(dialog.text_edit.toPlainText()) == max_chars
# Cursor should be clamped to max position
final_cursor = dialog.text_edit.textCursor()
assert final_cursor.position() <= max_chars
def test_bug_report_dialog_empty_text_shows_warning(qtbot, monkeypatch):
"""Test that sending empty report shows warning."""
strings.load_strings("en")
dialog = BugReportDialog()
qtbot.addWidget(dialog)
dialog.show()
# Clear any text
dialog.text_edit.clear()
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
# Try to send empty report
dialog._send()
assert warning_shown["shown"]
def test_bug_report_dialog_whitespace_only_shows_warning(qtbot, monkeypatch):
"""Test that sending whitespace-only report shows warning."""
strings.load_strings("en")
dialog = BugReportDialog()
qtbot.addWidget(dialog)
dialog.show()
# Set whitespace only
dialog.text_edit.setPlainText(" \n\n \t\t ")
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
dialog._send()
assert warning_shown["shown"]
def test_bug_report_dialog_network_error(qtbot, monkeypatch):
"""Test handling network error during send."""
strings.load_strings("en")
dialog = BugReportDialog()
qtbot.addWidget(dialog)
dialog.show()
dialog.text_edit.setPlainText("Test bug report")
# Mock requests.post to raise exception
import requests
def mock_post(*args, **kwargs):
raise requests.exceptions.ConnectionError("Network error")
monkeypatch.setattr(requests, "post", mock_post)
critical_shown = {"shown": False}
def mock_critical(*args):
critical_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
dialog._send()
assert critical_shown["shown"]
def test_bug_report_dialog_timeout_error(qtbot, monkeypatch):
"""Test handling timeout error during send."""
strings.load_strings("en")
dialog = BugReportDialog()
qtbot.addWidget(dialog)
dialog.show()
dialog.text_edit.setPlainText("Test bug report")
# Mock requests.post to raise timeout
import requests
def mock_post(*args, **kwargs):
raise requests.exceptions.Timeout("Request timed out")
monkeypatch.setattr(requests, "post", mock_post)
critical_shown = {"shown": False}
def mock_critical(*args):
critical_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
dialog._send()
assert critical_shown["shown"]

View file

@ -3,6 +3,7 @@ import json, csv
import datetime as dt import datetime as dt
from sqlcipher3 import dbapi2 as sqlite from sqlcipher3 import dbapi2 as sqlite
from bouquin.db import DBManager from bouquin.db import DBManager
from datetime import date, timedelta
def _today(): def _today():
@ -17,6 +18,10 @@ def _tomorrow():
return (dt.date.today() + dt.timedelta(days=1)).isoformat() return (dt.date.today() + dt.timedelta(days=1)).isoformat()
def _days_ago(n):
return (date.today() - timedelta(days=n)).isoformat()
def _entry(text, i=0): def _entry(text, i=0):
return f"{text} line {i}\nsecond line\n\n- [x] done\n- [ ] todo" return f"{text} line {i}\nsecond line\n\n- [x] done\n- [ ] todo"
@ -201,3 +206,333 @@ def test_integrity_check_raises_without_details(tmp_db_cfg):
db.conn = _Conn([(None,), (None,)]) db.conn = _Conn([(None,), (None,)])
with pytest.raises(sqlite.IntegrityError): with pytest.raises(sqlite.IntegrityError):
db._integrity_ok() db._integrity_ok()
# ============================================================================
# DB _strip_markdown and _count_words Tests
# ============================================================================
def test_db_strip_markdown_empty_text(fresh_db):
"""Test strip_markdown with empty text."""
result = fresh_db._strip_markdown("")
assert result == ""
def test_db_strip_markdown_none_text(fresh_db):
"""Test strip_markdown with None."""
result = fresh_db._strip_markdown(None)
assert result == ""
def test_db_strip_markdown_fenced_code_blocks(fresh_db):
"""Test stripping fenced code blocks."""
text = """
Some text here
```python
def hello():
print("world")
```
More text after
"""
result = fresh_db._strip_markdown(text)
assert "def hello" not in result
assert "Some text" in result
assert "More text" in result
def test_db_strip_markdown_inline_code(fresh_db):
"""Test stripping inline code."""
text = "Here is some `inline code` in text"
result = fresh_db._strip_markdown(text)
assert "`" not in result
assert "inline code" not in result
assert "Here is some" in result
assert "in text" in result
def test_db_strip_markdown_links(fresh_db):
"""Test converting markdown links to plain text."""
text = "Check out [this link](https://example.com) for more info"
result = fresh_db._strip_markdown(text)
assert "this link" in result
assert "https://example.com" not in result
assert "[" not in result
assert "]" not in result
def test_db_strip_markdown_emphasis_and_headers(fresh_db):
"""Test stripping emphasis markers and headers."""
text = """
# Header 1
## Header 2
**bold text** and *italic text*
> blockquote
_underline_
"""
result = fresh_db._strip_markdown(text)
assert "#" not in result
assert "*" not in result
assert "_" not in result
assert ">" not in result
assert "bold text" in result
assert "italic text" in result
def test_db_strip_markdown_html_tags(fresh_db):
"""Test stripping HTML tags."""
text = "Some <b>bold</b> and <i>italic</i> text with <div>divs</div>"
result = fresh_db._strip_markdown(text)
# The regex replaces tags with spaces, may leave some angle brackets from malformed HTML
# The important thing is that the words are preserved
assert "bold" in result
assert "italic" in result
assert "divs" in result
def test_db_strip_markdown_complex_document(fresh_db):
"""Test stripping complex markdown document."""
text = """
# My Document
This is a paragraph with **bold** and *italic* text.
```javascript
const x = 10;
console.log(x);
```
Here's a [link](https://example.com) and some `code`.
> A blockquote
<p>HTML paragraph</p>
"""
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 "<p>" 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) == 10
(
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,
) = 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,
) = 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, _, _, _, _ = 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, _ = 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 = 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 = 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, _, _, _, _ = 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, _ = 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

View file

@ -17,6 +17,12 @@ from bouquin.markdown_highlighter import MarkdownHighlighter
from bouquin.theme import ThemeManager, ThemeConfig, Theme from bouquin.theme import ThemeManager, ThemeConfig, Theme
def _today():
from datetime import date
return date.today().isoformat()
def text(editor) -> str: def text(editor) -> str:
return editor.toPlainText() return editor.toPlainText()
@ -1464,3 +1470,192 @@ def test_markdown_highlighter_switch_dark_mode(app):
both_valid = light_bg.isValid() and dark_bg.isValid() both_valid = light_bg.isValid() and dark_bg.isValid()
assert is_light_lighter or both_valid # At least colors are being set 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

View file

@ -1,10 +1,14 @@
import datetime as _dt import datetime as _dt
from PySide6.QtWidgets import QLabel
from bouquin.statistics_dialog import StatisticsDialog
from bouquin import strings from bouquin import strings
from datetime import date
from PySide6.QtCore import Qt, QPoint
from PySide6.QtWidgets import QLabel
from PySide6.QtTest import QTest
from bouquin.statistics_dialog import DateHeatmap, StatisticsDialog
class FakeStatsDB: class FakeStatsDB:
"""Minimal stub that returns a fixed stats payload.""" """Minimal stub that returns a fixed stats payload."""
@ -104,3 +108,312 @@ def test_statistics_dialog_no_data_shows_placeholder(qtbot):
# When there's no data, the heatmap and metric combo shouldn't exist # When there's no data, the heatmap and metric combo shouldn't exist
assert not hasattr(dlg, "metric_combo") assert not hasattr(dlg, "metric_combo")
assert not hasattr(dlg, "_heatmap") assert not hasattr(dlg, "_heatmap")
def _date(year, month, day):
return date(year, month, day)
# ============================================================================
# DateHeatmapTests - Missing Coverage
# ============================================================================
def test_activity_heatmap_empty_data(qtbot):
"""Test heatmap with empty data dict."""
strings.load_strings("en")
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Set empty data
heatmap.set_data({})
# Should handle empty data gracefully
assert heatmap._start is None
assert heatmap._end is None
assert heatmap._max_value == 0
# Size hint should return default dimensions
size = heatmap.sizeHint()
assert size.width() > 0
assert size.height() > 0
# Paint should not crash
heatmap.update()
qtbot.wait(10)
def test_activity_heatmap_none_data(qtbot):
"""Test heatmap with None data."""
strings.load_strings("en")
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Set None data
heatmap.set_data(None)
assert heatmap._start is None
assert heatmap._end is None
# Paint event should return early
heatmap.update()
qtbot.wait(10)
def test_activity_heatmap_click_when_no_data(qtbot):
"""Test clicking heatmap when there's no data."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
heatmap.set_data({})
# Simulate click - should not crash or emit signal
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Click in the middle of widget
pos = QPoint(100, 100)
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
# Should not have clicked any date
assert len(clicked_dates) == 0
def test_activity_heatmap_click_outside_grid(qtbot):
"""Test clicking outside the grid area."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Set some data
data = {
date(2024, 1, 1): 5,
date(2024, 1, 2): 10,
}
heatmap.set_data(data)
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Click in top-left margin (before grid starts)
pos = QPoint(5, 5)
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
assert len(clicked_dates) == 0
def test_activity_heatmap_click_beyond_end_date(qtbot):
"""Test clicking on trailing empty cells beyond the last date."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Set data that doesn't fill a complete week
data = {
date(2024, 1, 1): 5, # Monday
date(2024, 1, 2): 10, # Tuesday
}
heatmap.set_data(data)
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Try clicking far to the right (beyond end date)
# This is tricky to target precisely, but we can simulate
pos = QPoint(1000, 50) # Far right
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
# Should either not click or only click valid dates
# If it did click, it should be a valid date within range
if clicked_dates:
assert clicked_dates[0] <= date(2024, 1, 2)
def test_activity_heatmap_click_invalid_row(qtbot):
"""Test clicking below the 7 weekday rows."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
data = {
date(2024, 1, 1): 5,
date(2024, 1, 8): 10,
}
heatmap.set_data(data)
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Click below the grid (row 8 or higher)
pos = QPoint(100, 500) # Very low Y
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
assert len(clicked_dates) == 0
def test_activity_heatmap_right_click_ignored(qtbot):
"""Test that right-click is ignored."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
data = {date(2024, 1, 1): 5}
heatmap.set_data(data)
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Right click should be ignored
pos = QPoint(100, 100)
QTest.mouseClick(heatmap, Qt.RightButton, pos=pos)
assert len(clicked_dates) == 0
def test_activity_heatmap_month_label_rendering(qtbot):
"""Test heatmap spanning multiple months renders month labels."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Data spanning multiple months
data = {
date(2024, 1, 1): 5,
date(2024, 1, 15): 10,
date(2024, 2, 1): 8,
date(2024, 2, 15): 12,
date(2024, 3, 1): 6,
}
heatmap.set_data(data)
# Should calculate proper size
size = heatmap.sizeHint()
assert size.width() > 0
assert size.height() > 0
# Paint should work without crashing
heatmap.update()
qtbot.wait(10)
def test_activity_heatmap_same_month_continues(qtbot):
"""Test that month labels skip weeks in the same month."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
# Multiple dates in same month
data = {}
for day in range(1, 29): # January 1-28
data[date(2024, 1, day)] = day
heatmap.set_data(data)
# Should render without issues
heatmap.update()
qtbot.wait(10)
def test_activity_heatmap_data_with_zero_values(qtbot):
"""Test heatmap with zero values in data."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
data = {
date(2024, 1, 1): 0,
date(2024, 1, 2): 5,
date(2024, 1, 3): 0,
}
heatmap.set_data(data)
assert heatmap._max_value == 5
heatmap.update()
qtbot.wait(10)
def test_activity_heatmap_single_day(qtbot):
"""Test heatmap with just one day of data."""
heatmap = DateHeatmap()
qtbot.addWidget(heatmap)
heatmap.show()
data = {date(2024, 1, 15): 10}
heatmap.set_data(data)
# Should handle single day
assert heatmap._start is not None
assert heatmap._end is not None
clicked_dates = []
heatmap.date_clicked.connect(clicked_dates.append)
# Click should work
pos = QPoint(100, 100)
QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos)
# ============================================================================
# StatisticsDialog Tests
# ============================================================================
def test_statistics_dialog_with_empty_database(qtbot, fresh_db):
"""Test statistics dialog with an empty database."""
strings.load_strings("en")
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show()
# Should handle empty database gracefully
assert dialog.isVisible()
# Heatmap should be empty
heatmap = dialog.findChild(DateHeatmap)
if heatmap:
# No crash when displaying empty heatmap
qtbot.wait(10)
def test_statistics_dialog_with_data(qtbot, fresh_db):
"""Test statistics dialog with actual data."""
strings.load_strings("en")
# Add some content
fresh_db.save_new_version("2024-01-01", "Hello world", "test")
fresh_db.save_new_version("2024-01-02", "More content here", "test")
fresh_db.save_new_version("2024-01-03", "Even more text", "test")
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show()
# Should display statistics
assert dialog.isVisible()
qtbot.wait(10)
def test_statistics_dialog_gather_stats_exception_handling(
qtbot, fresh_db, monkeypatch
):
"""Test that gather_stats handles exceptions gracefully."""
strings.load_strings("en")
# Make dates_with_content raise an exception
def bad_dates_with_content():
raise RuntimeError("Simulated DB error")
monkeypatch.setattr(fresh_db, "dates_with_content", bad_dates_with_content)
# Should still create dialog without crashing
dialog = StatisticsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show()
# Should handle error gracefully
assert dialog.isVisible()

View file

@ -1,365 +0,0 @@
import numpy as np
import pytest
from pyqtgraph.Qt import QtCore
from bouquin.tag_graph_dialog import TagGraphDialog, DraggableGraphItem
# ---------------------------------------------------------------------------
# Helpers for DraggableGraphItem tests
# ---------------------------------------------------------------------------
class DummyPos:
"""Simple object with x()/y() so it looks like a QPointF to the code."""
def __init__(self, x, y):
self._x = float(x)
self._y = float(y)
def x(self):
return self._x
def y(self):
return self._y
class FakePoint:
"""Minimal object returned from scatter.pointsAt()."""
def __init__(self, idx):
self._idx = idx
def index(self):
return self._idx
class FakeDragEvent:
"""Stub object that looks like pyqtgraph's mouse drag event."""
def __init__(self, stage, button, start_pos, move_pos):
# stage is one of: "start", "move", "finish"
self._stage = stage
self._button = button
self._start_pos = start_pos
self._pos = move_pos
self.accepted = False
self.ignored = False
# Life-cycle ---------------------------------------------------------
def isStart(self):
return self._stage == "start"
def isFinish(self):
return self._stage == "finish"
# Buttons / positions ------------------------------------------------
def button(self):
return self._button
def buttonDownPos(self):
return self._start_pos
def pos(self):
return self._pos
# Accept / ignore ----------------------------------------------------
def accept(self):
self.accepted = True
def ignore(self):
self.ignored = True
class FakeHoverEvent:
"""Stub for hoverEvent tests."""
def __init__(self, pos=None, exit=False):
self._pos = pos
self._exit = exit
def isExit(self):
return self._exit
def pos(self):
return self._pos
# ---------------------------------------------------------------------------
# DraggableGraphItem
# ---------------------------------------------------------------------------
def test_draggable_graph_item_setdata_caches_kwargs(app):
item = DraggableGraphItem()
pos1 = np.array([[0.0, 0.0]], dtype=float)
adj = np.zeros((0, 2), dtype=int)
sizes = np.array([5.0], dtype=float)
# First call sets all kwargs
item.setData(pos=pos1, adj=adj, size=sizes)
assert item.pos is pos1
assert "adj" in item._data_kwargs
assert "size" in item._data_kwargs
# Second call only passes pos; cached kwargs should keep size/adj
pos2 = np.array([[1.0, 1.0]], dtype=float)
item.setData(pos=pos2)
assert item.pos is pos2
assert item._data_kwargs["adj"] is adj
# size should still be present and unchanged
assert np.all(item._data_kwargs["size"] == sizes)
def test_draggable_graph_item_drag_updates_position_and_calls_callback(app):
moved = []
def on_pos_changed(pos):
# Store a copy so later mutations don't affect our assertion
moved.append(np.array(pos, copy=True))
item = DraggableGraphItem(on_position_changed=on_pos_changed)
# Simple 2-node graph
pos = np.array([[0.0, 0.0], [5.0, 5.0]], dtype=float)
adj = np.array([[0, 1]], dtype=int)
item.setData(pos=pos, adj=adj, size=np.array([5.0, 5.0], dtype=float))
# Make pointsAt always return the first node
item.scatter.pointsAt = lambda p: [FakePoint(0)]
# Start drag on node 0 at (0, 0)
start_ev = FakeDragEvent(
stage="start",
button=QtCore.Qt.MouseButton.LeftButton,
start_pos=DummyPos(0.0, 0.0),
move_pos=None,
)
item.mouseDragEvent(start_ev)
assert item._drag_index == 0
assert start_ev.accepted
assert not start_ev.ignored
# Move mouse to (2, 3) node 0 should follow exactly
move_ev = FakeDragEvent(
stage="move",
button=QtCore.Qt.MouseButton.LeftButton,
start_pos=DummyPos(0.0, 0.0),
move_pos=DummyPos(2.0, 3.0),
)
item.mouseDragEvent(move_ev)
assert move_ev.accepted
assert not move_ev.ignored
assert item.pos.shape == (2, 2)
assert item.pos[0, 0] == pytest.approx(2.0)
assert item.pos[0, 1] == pytest.approx(3.0)
# Callback should have been invoked with the updated positions
assert moved, "on_position_changed should be called at least once"
np.testing.assert_allclose(moved[-1][0], [2.0, 3.0], atol=1e-6)
# Finish drag: internal state should reset
finish_ev = FakeDragEvent(
stage="finish",
button=QtCore.Qt.MouseButton.LeftButton,
start_pos=DummyPos(0.0, 0.0),
move_pos=DummyPos(2.0, 3.0),
)
item.mouseDragEvent(finish_ev)
assert finish_ev.accepted
assert item._drag_index is None
assert item._drag_offset is None
def test_draggable_graph_item_ignores_non_left_button(app):
item = DraggableGraphItem()
pos = np.array([[0.0, 0.0]], dtype=float)
adj = np.zeros((0, 2), dtype=int)
item.setData(pos=pos, adj=adj, size=np.array([5.0], dtype=float))
# pointsAt would return something, but the button is not left,
# so the event should be ignored.
item.scatter.pointsAt = lambda p: [FakePoint(0)]
ev = FakeDragEvent(
stage="start",
button=QtCore.Qt.MouseButton.RightButton,
start_pos=DummyPos(0.0, 0.0),
move_pos=None,
)
item.mouseDragEvent(ev)
assert ev.ignored
assert not ev.accepted
assert item._drag_index is None
def test_draggable_graph_item_hover_reports_index_and_exit(app):
hovered = []
def on_hover(idx, ev):
hovered.append(idx)
item = DraggableGraphItem(on_hover=on_hover)
# Case 1: exit event should report None
ev_exit = FakeHoverEvent(exit=True)
item.hoverEvent(ev_exit)
assert hovered[-1] is None
# Case 2: no points under mouse -> None
item.scatter.pointsAt = lambda p: []
ev_none = FakeHoverEvent(pos=DummyPos(0.0, 0.0))
item.hoverEvent(ev_none)
assert hovered[-1] is None
# Case 3: one point under mouse -> its index
item.scatter.pointsAt = lambda p: [FakePoint(3)]
ev_hit = FakeHoverEvent(pos=DummyPos(1.0, 2.0))
item.hoverEvent(ev_hit)
assert hovered[-1] == 3
# ---------------------------------------------------------------------------
# TagGraphDialog
# ---------------------------------------------------------------------------
class EmptyTagDB:
"""DB stub that returns no tag data."""
def get_tag_cooccurrences(self):
return {}, [], {}
class SimpleTagDB:
"""Deterministic stub for tag co-occurrence data."""
def __init__(self):
self.called = False
def get_tag_cooccurrences(self):
self.called = True
tags_by_id = {
1: (1, "alpha", "#ff0000"),
2: (2, "beta", "#00ff00"),
3: (3, "gamma", "#0000ff"),
}
edges = [
(1, 2, 3),
(2, 3, 1),
]
tag_page_counts = {1: 5, 2: 3, 3: 1}
return tags_by_id, edges, tag_page_counts
def test_tag_graph_dialog_handles_empty_db(app, qtbot):
dlg = TagGraphDialog(EmptyTagDB())
qtbot.addWidget(dlg)
dlg.show()
# When there are no tags, nothing should be populated
assert dlg._tag_ids == []
assert dlg._label_items == []
assert dlg._tag_names == {}
assert dlg._tag_page_counts == {}
def test_tag_graph_dialog_populates_graph_from_db(app, qtbot):
db = SimpleTagDB()
dlg = TagGraphDialog(db)
qtbot.addWidget(dlg)
dlg.show()
assert db.called
# Basic invariants about the populated state
assert dlg._tag_ids == [1, 2, 3]
assert dlg._tag_names[1] == "alpha"
assert dlg._tag_page_counts[1] == 5
# GraphItem should have one position per node
assert dlg.graph_item.pos.shape == (3, 2)
# Labels and halo state should match number of tags
assert len(dlg._label_items) == 3
assert len(dlg._halo_sizes) == 3
assert len(dlg._halo_brushes) == 3
def test_tag_graph_dialog_on_positions_changed_updates_labels_and_halo(
app, qtbot, monkeypatch
):
db = SimpleTagDB()
dlg = TagGraphDialog(db)
qtbot.addWidget(dlg)
dlg.show()
assert len(dlg._label_items) == 3
# Set up fake halo sizes/brushes so the halo branch runs
dlg._halo_sizes = [10.0, 20.0, 30.0]
dlg._halo_brushes = ["a", "b", "c"]
captured = {}
def fake_set_data(*, x, y, size, brush, pen):
captured["x"] = x
captured["y"] = y
captured["size"] = size
captured["brush"] = brush
captured["pen"] = pen
monkeypatch.setattr(dlg._halo_item, "setData", fake_set_data)
# New layout positions
pos = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], dtype=float)
dlg._on_positions_changed(pos)
# Each label should be slightly below its node (y + 0.15)
for i, label in enumerate(dlg._label_items):
assert label.pos().x() == pytest.approx(pos[i, 0])
assert label.pos().y() == pytest.approx(pos[i, 1] + 0.15)
# Halo layer should receive the updated coordinates and our sizes/brushes
assert captured["x"] == [1.0, 3.0, 5.0]
assert captured["y"] == [2.0, 4.0, 6.0]
assert captured["size"] == dlg._halo_sizes
assert captured["brush"] == dlg._halo_brushes
assert captured["pen"] is None
def test_tag_graph_dialog_hover_index_shows_and_hides_tooltip(app, qtbot, monkeypatch):
db = SimpleTagDB()
dlg = TagGraphDialog(db)
qtbot.addWidget(dlg)
dlg.show()
shown = {}
hidden = {"called": False}
def fake_show_text(pos, text, widget):
shown["pos"] = pos
shown["text"] = text
shown["widget"] = widget
def fake_hide_text():
hidden["called"] = True
# Patch the module-level QToolTip used by TagGraphDialog
monkeypatch.setattr("bouquin.tag_graph_dialog.QToolTip.showText", fake_show_text)
monkeypatch.setattr("bouquin.tag_graph_dialog.QToolTip.hideText", fake_hide_text)
# Hover over first node (index 0)
dlg._on_hover_index(0, ev=None)
assert "alpha" in shown["text"]
assert "page" in shown["text"]
assert shown["widget"] is dlg
# Now simulate leaving the item entirely
dlg._on_hover_index(None, ev=None)
assert hidden["called"]

View file

@ -1,6 +1,6 @@
import pytest import pytest
from PySide6.QtCore import Qt, QPoint, QEvent from PySide6.QtCore import Qt, QPoint, QEvent
from PySide6.QtGui import QMouseEvent from PySide6.QtGui import QMouseEvent, QColor
from PySide6.QtWidgets import QApplication, QMessageBox, QInputDialog, QColorDialog from PySide6.QtWidgets import QApplication, QMessageBox, QInputDialog, QColorDialog
from bouquin.db import DBManager from bouquin.db import DBManager
from bouquin.strings import load_strings from bouquin.strings import load_strings
@ -9,6 +9,8 @@ from bouquin.tag_browser import TagBrowserDialog
from bouquin.flow_layout import FlowLayout from bouquin.flow_layout import FlowLayout
from sqlcipher3.dbapi2 import IntegrityError from sqlcipher3.dbapi2 import IntegrityError
import bouquin.strings as strings
# ============================================================================ # ============================================================================
# DB Layer Tag Tests # DB Layer Tag Tests
@ -1798,3 +1800,360 @@ def test_multiple_widgets_same_database(app, fresh_db):
widget2._on_toggle(True) widget2._on_toggle(True)
assert widget2.chip_layout.count() == 1 assert widget2.chip_layout.count() == 1
def test_tag_browser_add_tag_with_color(qtbot, fresh_db, monkeypatch):
"""Test adding a new tag with color selection."""
strings.load_strings("en")
browser = TagBrowserDialog(fresh_db)
qtbot.addWidget(browser)
browser.show()
# Mock input dialog and color dialog
def mock_get_text(*args, **kwargs):
return "NewTag", True
def mock_get_color(initial, parent):
return QColor("#ff0000")
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
monkeypatch.setattr(QColorDialog, "getColor", mock_get_color)
tags_before = len(fresh_db.list_tags())
# Trigger add tag
browser._add_a_tag()
# Should have added tag
tags_after = len(fresh_db.list_tags())
assert tags_after == tags_before + 1
def test_tag_browser_add_tag_cancelled_at_name(qtbot, fresh_db, monkeypatch):
"""Test cancelling tag addition at name input."""
strings.load_strings("en")
browser = TagBrowserDialog(fresh_db)
qtbot.addWidget(browser)
browser.show()
# Mock cancelled input
def mock_get_text(*args, **kwargs):
return "", False
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
tags_before = len(fresh_db.list_tags())
browser._add_a_tag()
# Should not have added tag
tags_after = len(fresh_db.list_tags())
assert tags_after == tags_before
def test_tag_browser_add_tag_cancelled_at_color(qtbot, fresh_db, monkeypatch):
"""Test cancelling tag addition at color selection."""
strings.load_strings("en")
browser = TagBrowserDialog(fresh_db)
qtbot.addWidget(browser)
browser.show()
# Name input succeeds, color cancelled
def mock_get_text(*args, **kwargs):
return "NewTag", True
def mock_get_color(initial, parent):
return QColor() # Invalid color = cancelled
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
monkeypatch.setattr(QColorDialog, "getColor", mock_get_color)
tags_before = len(fresh_db.list_tags())
browser._add_a_tag()
# Should not have added tag
tags_after = len(fresh_db.list_tags())
assert tags_after == tags_before
def test_tag_browser_add_duplicate_tag_shows_error(qtbot, fresh_db, monkeypatch):
"""Test adding duplicate tag shows error."""
strings.load_strings("en")
# Add existing tag
fresh_db.add_tag("Existing", "#ff0000")
browser = TagBrowserDialog(fresh_db)
qtbot.addWidget(browser)
browser.show()
# Try to add same tag
def mock_get_text(*args, **kwargs):
return "Existing", True
def mock_get_color(initial, parent):
return QColor("#00ff00")
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
monkeypatch.setattr(QColorDialog, "getColor", mock_get_color)
critical_shown = {"shown": False}
def mock_critical(*args):
critical_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
browser._add_a_tag()
# Should show error
assert critical_shown["shown"]
def test_tag_browser_edit_tag_integrity_error(qtbot, fresh_db, monkeypatch):
"""Test editing tag to duplicate name shows error."""
strings.load_strings("en")
# Add two tags
fresh_db.add_tag("Tag1", "#ff0000")
fresh_db.add_tag("Tag2", "#00ff00")
browser = TagBrowserDialog(fresh_db)
qtbot.addWidget(browser)
browser.show()
# Select first tag
browser._populate(None)
tree = browser.tree
if tree.topLevelItemCount() > 0:
tree.setCurrentItem(tree.topLevelItem(0))
# Try to rename to Tag2 (duplicate)
def mock_get_text(*args, **kwargs):
return "Tag2", True
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
critical_shown = {"shown": False}
def mock_critical(*args):
critical_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
browser._edit_tag_name()
# Should show error
assert critical_shown["shown"]
def test_tag_browser_change_tag_color_integrity_error(qtbot, fresh_db, monkeypatch):
"""Test changing tag color with integrity error."""
strings.load_strings("en")
fresh_db.add_tag("TestTag", "#ff0000")
browser = TagBrowserDialog(fresh_db)
qtbot.addWidget(browser)
browser.show()
browser._populate(None)
tree = browser.tree
if tree.topLevelItemCount() > 0:
tree.setCurrentItem(tree.topLevelItem(0))
# Mock color dialog
def mock_get_color(initial, parent):
return QColor("#00ff00")
monkeypatch.setattr(QColorDialog, "getColor", mock_get_color)
# Mock update_tag to raise IntegrityError
fresh_db.update_tag
def bad_update(*args):
raise IntegrityError("Simulated error")
monkeypatch.setattr(fresh_db, "update_tag", bad_update)
critical_shown = {"shown": False}
def mock_critical(*args):
critical_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
browser._change_tag_color()
# Should show error
assert critical_shown["shown"]
def test_tag_browser_change_tag_color_cancelled(qtbot, fresh_db, monkeypatch):
"""Test cancelling color change."""
strings.load_strings("en")
fresh_db.add_tag("TestTag", "#ff0000")
browser = TagBrowserDialog(fresh_db)
qtbot.addWidget(browser)
browser.show()
browser._populate(None)
tree = browser.tree
if tree.topLevelItemCount() > 0:
tree.setCurrentItem(tree.topLevelItem(0))
# Mock cancelled color dialog
def mock_get_color(initial, parent):
return QColor() # Invalid = cancelled
monkeypatch.setattr(QColorDialog, "getColor", mock_get_color)
# Should not crash
browser._change_tag_color()
def test_tag_chip_runtime_error_on_mouse_release(qtbot, monkeypatch):
"""Test TagChip handles RuntimeError on mouseReleaseEvent."""
chip = TagChip(1, "test", "#ff0000")
qtbot.addWidget(chip)
chip.show()
# Mock super().mouseReleaseEvent to raise RuntimeError
from PySide6.QtWidgets import QFrame
original_mouse_release = QFrame.mouseReleaseEvent
def bad_mouse_release(self, event):
raise RuntimeError("Widget deleted")
monkeypatch.setattr(QFrame, "mouseReleaseEvent", bad_mouse_release)
clicked_names = []
chip.clicked.connect(clicked_names.append)
# Simulate left click
from PySide6.QtTest import QTest
QTest.mouseClick(chip, Qt.LeftButton)
# Should have emitted signal despite RuntimeError
assert "test" in clicked_names
# Restore original
monkeypatch.setattr(QFrame, "mouseReleaseEvent", original_mouse_release)
def test_page_tags_widget_many_tags(qtbot, fresh_db):
"""Test page tags widget with many tags."""
strings.load_strings("en")
# Add many tags
for i in range(20):
fresh_db.add_tag(f"Tag{i}", f"#{i:02x}0000")
fresh_db.save_new_version("2024-01-01", "Content", "test")
# Add all tags to page
tag_names = [f"Tag{i}" for i in range(20)]
fresh_db.set_tags_for_page("2024-01-01", tag_names)
widget = PageTagsWidget(fresh_db)
qtbot.addWidget(widget)
widget.show()
# Set current date
widget.set_current_date("2024-01-01")
# Should display all tags
qtbot.wait(50)
def test_page_tags_widget_tag_click(qtbot, fresh_db):
"""Test clicking on a tag in PageTagsWidget."""
strings.load_strings("en")
fresh_db.add_tag("Clickable", "#ff0000")
fresh_db.save_new_version("2024-01-01", "Content", "test")
fresh_db.set_tags_for_page("2024-01-01", ["Clickable"])
widget = PageTagsWidget(fresh_db)
qtbot.addWidget(widget)
widget.show()
widget.set_current_date("2024-01-01")
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
# Find the tag chip
chips = widget.findChildren(TagChip)
assert len(chips) > 0
# Click it - shouldn't crash
from PySide6.QtTest import QTest
QTest.mouseClick(chips[0], Qt.LeftButton)
def test_page_tags_widget_no_date_set(qtbot, fresh_db):
"""Test PageTagsWidget with no date set."""
strings.load_strings("en")
widget = PageTagsWidget(fresh_db)
qtbot.addWidget(widget)
widget.show()
# Should handle no date gracefully
widget.set_current_date(None)
qtbot.wait(10)
def test_page_tags_widget_date_with_no_tags(qtbot, fresh_db):
"""Test PageTagsWidget for date with no tags."""
strings.load_strings("en")
fresh_db.save_new_version("2024-01-01", "Content", "test")
widget = PageTagsWidget(fresh_db)
qtbot.addWidget(widget)
widget.show()
widget.set_current_date("2024-01-01")
# Should show no tags
pills = widget.findChildren(TagChip)
assert len(pills) == 0
def test_page_tags_widget_updates_on_tag_change(qtbot, fresh_db):
"""Test PageTagsWidget updates when tags change."""
strings.load_strings("en")
fresh_db.add_tag("Initial", "#ff0000")
fresh_db.save_new_version("2024-01-01", "Content", "test")
fresh_db.set_tags_for_page("2024-01-01", ["Initial"])
widget = PageTagsWidget(fresh_db)
qtbot.addWidget(widget)
widget.show()
widget.set_current_date("2024-01-01")
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
assert widget.chip_layout.count() == 1
# Add another tag
fresh_db.add_tag("Second", "#00ff00")
fresh_db.set_tags_for_page("2024-01-01", ["Initial", "Second"])
# Reload
widget.set_current_date("2024-01-01")
qtbot.wait(100)
assert widget.chip_layout.count() == 2

2558
tests/test_time_log.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -3,15 +3,11 @@ from bouquin.flow_layout import FlowLayout
from bouquin.markdown_editor import MarkdownEditor from bouquin.markdown_editor import MarkdownEditor
from bouquin.markdown_highlighter import MarkdownHighlighter from bouquin.markdown_highlighter import MarkdownHighlighter
from bouquin.statistics_dialog import DateHeatMap from bouquin.statistics_dialog import DateHeatMap
from bouquin.tag_graph_dialog import DraggableGraphItem
DBManager.row_factory DBManager.row_factory
DateHeatMap.minimumSizeHint DateHeatMap.minimumSizeHint
DraggableGraphItem.hoverEvent
DraggableGraphItem.mouseDragEvent
FlowLayout.itemAt FlowLayout.itemAt
FlowLayout.expandingDirections FlowLayout.expandingDirections
FlowLayout.hasHeightForWidth FlowLayout.hasHeightForWidth