bouquin 0.1.1__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of bouquin might be problematic. Click here for more details.

@@ -1,7 +1,8 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bouquin
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
5
+ Home-page: https://git.mig5.net/mig5/bouquin
5
6
  License: GPL-3.0-or-later
6
7
  Author: Miguel Jacq
7
8
  Author-email: mig@mig5.net
@@ -14,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.11
14
15
  Classifier: Programming Language :: Python :: 3.12
15
16
  Requires-Dist: pyside6 (>=6.8.1,<7.0.0)
16
17
  Requires-Dist: sqlcipher3-wheels (>=0.5.5.post0,<0.6.0)
18
+ Project-URL: Repository, https://git.mig5.net/mig5/bouquin
17
19
  Description-Content-Type: text/markdown
18
20
 
19
21
  # Bouquin
@@ -27,7 +29,7 @@ It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a dr
27
29
  for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
28
30
 
29
31
  To increase security, the SQLCipher key is requested when the app is opened, and is not written
30
- to disk.
32
+ to disk unless the user configures it to be in the settings.
31
33
 
32
34
  There is deliberately no network connectivity or syncing intended.
33
35
 
@@ -37,23 +39,21 @@ There is deliberately no network connectivity or syncing intended.
37
39
 
38
40
  ## Features
39
41
 
42
+ * Data is encrypted at rest
43
+ * Encryption key is prompted for and never stored, unless user chooses to via Settings
40
44
  * Every 'page' is linked to the calendar day
41
- * Basic markdown
45
+ * Text is HTML with basic styling
46
+ * Search
42
47
  * Automatic periodic saving (or explicitly save)
43
- * Navigating from one day to the next automatically saves
44
- * Basic keyboard shortcuts
45
48
  * Transparent integrity checking of the database when it opens
46
-
47
-
48
- ## Yet to do
49
-
50
- * Search
51
- * Taxonomy/tagging
52
- * Export to other formats (plaintext, json, sql etc)
49
+ * Rekey the database (change the password)
50
+ * Export the database to json, txt, html or csv
53
51
 
54
52
 
55
53
  ## How to install
56
54
 
55
+ Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
56
+
57
57
  ### From source
58
58
 
59
59
  * Clone this repo or download the tarball from the releases page
@@ -65,7 +65,7 @@ There is deliberately no network connectivity or syncing intended.
65
65
 
66
66
  * Download the whl and run it
67
67
 
68
- ### From PyPi
68
+ ### From PyPi/pip
69
69
 
70
70
  * `pip install bouquin`
71
71
 
@@ -9,7 +9,7 @@ It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a dr
9
9
  for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
10
10
 
11
11
  To increase security, the SQLCipher key is requested when the app is opened, and is not written
12
- to disk.
12
+ to disk unless the user configures it to be in the settings.
13
13
 
14
14
  There is deliberately no network connectivity or syncing intended.
15
15
 
@@ -19,23 +19,21 @@ There is deliberately no network connectivity or syncing intended.
19
19
 
20
20
  ## Features
21
21
 
22
+ * Data is encrypted at rest
23
+ * Encryption key is prompted for and never stored, unless user chooses to via Settings
22
24
  * Every 'page' is linked to the calendar day
23
- * Basic markdown
25
+ * Text is HTML with basic styling
26
+ * Search
24
27
  * Automatic periodic saving (or explicitly save)
25
- * Navigating from one day to the next automatically saves
26
- * Basic keyboard shortcuts
27
28
  * Transparent integrity checking of the database when it opens
28
-
29
-
30
- ## Yet to do
31
-
32
- * Search
33
- * Taxonomy/tagging
34
- * Export to other formats (plaintext, json, sql etc)
29
+ * Rekey the database (change the password)
30
+ * Export the database to json, txt, html or csv
35
31
 
36
32
 
37
33
  ## How to install
38
34
 
35
+ Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
36
+
39
37
  ### From source
40
38
 
41
39
  * Clone this repo or download the tarball from the releases page
@@ -47,7 +45,7 @@ There is deliberately no network connectivity or syncing intended.
47
45
 
48
46
  * Download the whl and run it
49
47
 
50
- ### From PyPi
48
+ ### From PyPi/pip
51
49
 
52
50
  * `pip install bouquin`
53
51
 
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import html
5
+ import json
6
+ import os
7
+
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from sqlcipher3 import dbapi2 as sqlite
11
+ from typing import List, Sequence, Tuple
12
+
13
+ Entry = Tuple[str, str]
14
+
15
+
16
+ @dataclass
17
+ class DBConfig:
18
+ path: Path
19
+ key: str
20
+
21
+
22
+ class DBManager:
23
+ def __init__(self, cfg: DBConfig):
24
+ self.cfg = cfg
25
+ self.conn: sqlite.Connection | None = None
26
+
27
+ def connect(self) -> bool:
28
+ # Ensure parent dir exists
29
+ self.cfg.path.parent.mkdir(parents=True, exist_ok=True)
30
+ self.conn = sqlite.connect(str(self.cfg.path))
31
+ self.conn.row_factory = sqlite.Row
32
+ cur = self.conn.cursor()
33
+ cur.execute(f"PRAGMA key = '{self.cfg.key}';")
34
+ cur.execute("PRAGMA journal_mode = WAL;")
35
+ self.conn.commit()
36
+ try:
37
+ self._integrity_ok()
38
+ except Exception:
39
+ self.conn.close()
40
+ self.conn = None
41
+ return False
42
+ self._ensure_schema()
43
+ return True
44
+
45
+ def _integrity_ok(self) -> bool:
46
+ cur = self.conn.cursor()
47
+ cur.execute("PRAGMA cipher_integrity_check;")
48
+ rows = cur.fetchall()
49
+
50
+ # OK
51
+ if not rows:
52
+ return
53
+
54
+ # Not OK
55
+ details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None)
56
+ raise sqlite.IntegrityError(
57
+ "SQLCipher integrity check failed"
58
+ + (f": {details}" if details else f" ({len(rows)} issue(s) reported)")
59
+ )
60
+
61
+ def _ensure_schema(self) -> None:
62
+ cur = self.conn.cursor()
63
+ cur.execute(
64
+ """
65
+ CREATE TABLE IF NOT EXISTS entries (
66
+ date TEXT PRIMARY KEY, -- ISO yyyy-MM-dd
67
+ content TEXT NOT NULL
68
+ );
69
+ """
70
+ )
71
+ cur.execute("PRAGMA user_version = 1;")
72
+ self.conn.commit()
73
+
74
+ def rekey(self, new_key: str) -> None:
75
+ """
76
+ Change the SQLCipher passphrase in-place, then reopen the connection
77
+ with the new key to verify.
78
+ """
79
+ if self.conn is None:
80
+ raise RuntimeError("Database is not connected")
81
+ cur = self.conn.cursor()
82
+ # Change the encryption key of the currently open database
83
+ cur.execute(f"PRAGMA rekey = '{new_key}';")
84
+ self.conn.commit()
85
+
86
+ # Close and reopen with the new key to verify and restore PRAGMAs
87
+ self.conn.close()
88
+ self.conn = None
89
+ self.cfg.key = new_key
90
+ if not self.connect():
91
+ raise sqlite.Error("Re-open failed after rekey")
92
+
93
+ def get_entry(self, date_iso: str) -> str:
94
+ cur = self.conn.cursor()
95
+ cur.execute("SELECT content FROM entries WHERE date = ?;", (date_iso,))
96
+ row = cur.fetchone()
97
+ return row[0] if row else ""
98
+
99
+ def upsert_entry(self, date_iso: str, content: str) -> None:
100
+ cur = self.conn.cursor()
101
+ cur.execute(
102
+ """
103
+ INSERT INTO entries(date, content) VALUES(?, ?)
104
+ ON CONFLICT(date) DO UPDATE SET content = excluded.content;
105
+ """,
106
+ (date_iso, content),
107
+ )
108
+ self.conn.commit()
109
+
110
+ def search_entries(self, text: str) -> list[str]:
111
+ cur = self.conn.cursor()
112
+ pattern = f"%{text}%"
113
+ return cur.execute(
114
+ "SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,)
115
+ ).fetchall()
116
+
117
+ def dates_with_content(self) -> list[str]:
118
+ cur = self.conn.cursor()
119
+ cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
120
+ return [r[0] for r in cur.fetchall()]
121
+
122
+ def get_all_entries(self) -> List[Entry]:
123
+ cur = self.conn.cursor()
124
+ rows = cur.execute("SELECT date, content FROM entries ORDER BY date").fetchall()
125
+ return [(row["date"], row["content"]) for row in rows]
126
+
127
+ def export_json(
128
+ self, entries: Sequence[Entry], file_path: str, pretty: bool = True
129
+ ) -> None:
130
+ data = [{"date": d, "content": c} for d, c in entries]
131
+ with open(file_path, "w", encoding="utf-8") as f:
132
+ if pretty:
133
+ json.dump(data, f, ensure_ascii=False, indent=2)
134
+ else:
135
+ json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
136
+
137
+ def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
138
+ # utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
139
+ with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
140
+ writer = csv.writer(f)
141
+ writer.writerow(["date", "content"]) # header
142
+ writer.writerows(entries)
143
+
144
+ def export_txt(
145
+ self,
146
+ entries: Sequence[Entry],
147
+ file_path: str,
148
+ separator: str = "\n\n— — — — —\n\n",
149
+ strip_html: bool = True,
150
+ ) -> None:
151
+ import re, html as _html
152
+
153
+ # Precompiled patterns
154
+ STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
155
+ COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
156
+ BR_RE = re.compile(r"(?i)<br\\s*/?>")
157
+ BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\\s*>")
158
+ TAG_RE = re.compile(r"<[^>]+>")
159
+ WS_ENDS_RE = re.compile(r"[ \\t]+\\n")
160
+ MULTINEWLINE_RE = re.compile(r"\\n{3,}")
161
+
162
+ def _strip(s: str) -> str:
163
+ # 1) Remove <style> and <script> blocks *including their contents*
164
+ s = STYLE_SCRIPT_RE.sub("", s)
165
+ # 2) Remove HTML comments
166
+ s = COMMENT_RE.sub("", s)
167
+ # 3) Turn some block-ish boundaries into newlines before removing tags
168
+ s = BR_RE.sub("\n", s)
169
+ s = BLOCK_END_RE.sub("\n", s)
170
+ # 4) Drop remaining tags
171
+ s = TAG_RE.sub("", s)
172
+ # 5) Unescape entities (&nbsp; etc.)
173
+ s = _html.unescape(s)
174
+ # 6) Tidy whitespace
175
+ s = WS_ENDS_RE.sub("\n", s)
176
+ s = MULTINEWLINE_RE.sub("\n\n", s)
177
+ return s.strip()
178
+
179
+ with open(file_path, "w", encoding="utf-8") as f:
180
+ for i, (d, c) in enumerate(entries):
181
+ body = _strip(c) if strip_html else c
182
+ f.write(f"{d}\n{body}\n")
183
+ if i < len(entries) - 1:
184
+ f.write(separator)
185
+
186
+ def export_html(
187
+ self, entries: Sequence[Entry], file_path: str, title: str = "Entries export"
188
+ ) -> None:
189
+ parts = [
190
+ "<!doctype html>",
191
+ '<html lang="en">',
192
+ '<meta charset="utf-8">',
193
+ f"<title>{html.escape(title)}</title>",
194
+ "<style>body{font:16px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;padding:24px;max-width:900px;margin:auto;}",
195
+ "article{padding:16px 0;border-bottom:1px solid #ddd;} time{font-weight:600;color:#333;} section{margin-top:8px;}</style>",
196
+ "<body>",
197
+ f"<h1>{html.escape(title)}</h1>",
198
+ ]
199
+ for d, c in entries:
200
+ parts.append(
201
+ f"<article><header><time>{html.escape(d)}</time></header><section>{c}</section></article>"
202
+ )
203
+ parts.append("</body></html>")
204
+
205
+ with open(file_path, "w", encoding="utf-8") as f:
206
+ f.write("\n".join(parts))
207
+
208
+ def export_by_extension(self, file_path: str) -> None:
209
+ entries = self.get_all_entries()
210
+ ext = os.path.splitext(file_path)[1].lower()
211
+
212
+ if ext == ".json":
213
+ self.export_json(entries, file_path)
214
+ elif ext == ".csv":
215
+ self.export_csv(entries, file_path)
216
+ elif ext == ".txt":
217
+ self.export_txt(entries, file_path)
218
+ elif ext in {".html", ".htm"}:
219
+ self.export_html(entries, file_path)
220
+ else:
221
+ raise ValueError(f"Unsupported extension: {ext}")
222
+
223
+ def close(self) -> None:
224
+ if self.conn is not None:
225
+ self.conn.close()
226
+ self.conn = None
@@ -0,0 +1,248 @@
1
+ from __future__ import annotations
2
+
3
+ from PySide6.QtGui import (
4
+ QColor,
5
+ QDesktopServices,
6
+ QFont,
7
+ QFontDatabase,
8
+ QTextCharFormat,
9
+ QTextCursor,
10
+ QTextListFormat,
11
+ QTextBlockFormat,
12
+ )
13
+ from PySide6.QtCore import Qt, QUrl, Signal, Slot, QRegularExpression
14
+ from PySide6.QtWidgets import QTextEdit
15
+
16
+
17
+ class Editor(QTextEdit):
18
+ linkActivated = Signal(str)
19
+
20
+ _URL_RX = QRegularExpression(r"(https?://[^\s<>\"]+|www\.[^\s<>\"]+)")
21
+
22
+ def __init__(self, *args, **kwargs):
23
+ super().__init__(*args, **kwargs)
24
+ tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
25
+ self.setTabStopDistance(tab_w)
26
+
27
+ self.setTextInteractionFlags(
28
+ Qt.TextInteractionFlag.TextEditorInteraction
29
+ | Qt.TextInteractionFlag.LinksAccessibleByMouse
30
+ | Qt.TextInteractionFlag.LinksAccessibleByKeyboard
31
+ )
32
+
33
+ self.setAcceptRichText(True)
34
+
35
+ # Turn raw URLs into anchors
36
+ self._linkifying = False
37
+ self.textChanged.connect(self._linkify_document)
38
+ self.viewport().setMouseTracking(True)
39
+
40
+ def _linkify_document(self):
41
+ if self._linkifying:
42
+ return
43
+ self._linkifying = True
44
+
45
+ doc = self.document()
46
+ cur = QTextCursor(doc)
47
+ cur.beginEditBlock()
48
+
49
+ block = doc.begin()
50
+ while block.isValid():
51
+ text = block.text()
52
+ it = self._URL_RX.globalMatch(text)
53
+ while it.hasNext():
54
+ m = it.next()
55
+ start = block.position() + m.capturedStart()
56
+ end = start + m.capturedLength()
57
+
58
+ cur.setPosition(start)
59
+ cur.setPosition(end, QTextCursor.KeepAnchor)
60
+
61
+ fmt = cur.charFormat()
62
+ if fmt.isAnchor(): # already linkified; skip
63
+ continue
64
+
65
+ href = m.captured(0)
66
+ if href.startswith("www."):
67
+ href = "https://" + href
68
+
69
+ fmt.setAnchor(True)
70
+ # Qt 6: use setAnchorHref; for compatibility, also set names.
71
+ try:
72
+ fmt.setAnchorHref(href)
73
+ except AttributeError:
74
+ fmt.setAnchorNames([href])
75
+
76
+ fmt.setFontUnderline(True)
77
+ fmt.setForeground(Qt.blue)
78
+ cur.setCharFormat(fmt)
79
+
80
+ block = block.next()
81
+
82
+ cur.endEditBlock()
83
+ self._linkifying = False
84
+
85
+ def mouseReleaseEvent(self, e):
86
+ if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
87
+ href = self.anchorAt(e.pos())
88
+ if href:
89
+ QDesktopServices.openUrl(QUrl.fromUserInput(href))
90
+ self.linkActivated.emit(href)
91
+ return
92
+ super().mouseReleaseEvent(e)
93
+
94
+ def mouseMoveEvent(self, e):
95
+ if (e.modifiers() & Qt.ControlModifier) and self.anchorAt(e.pos()):
96
+ self.viewport().setCursor(Qt.PointingHandCursor)
97
+ else:
98
+ self.viewport().setCursor(Qt.IBeamCursor)
99
+ super().mouseMoveEvent(e)
100
+
101
+ def keyPressEvent(self, e):
102
+ key = e.key()
103
+
104
+ # Pre-insert: stop link/format bleed for “word boundary” keys
105
+ if key in (Qt.Key_Space, Qt.Key_Tab):
106
+ self._break_anchor_for_next_char()
107
+ return super().keyPressEvent(e)
108
+
109
+ # When pressing Enter/return key, insert first, then neutralise the empty block’s inline format
110
+ if key in (Qt.Key_Return, Qt.Key_Enter):
111
+ super().keyPressEvent(e) # create the new (possibly empty) paragraph
112
+
113
+ # If we're on an empty block, clear the insertion char format so the
114
+ # *next* Enter will create another new line (not consume the press to reset formatting).
115
+ c = self.textCursor()
116
+ block = c.block()
117
+ if block.length() == 1:
118
+ self._clear_insertion_char_format()
119
+ return
120
+
121
+ return super().keyPressEvent(e)
122
+
123
+ def _clear_insertion_char_format(self):
124
+ """Reset inline typing format (keeps lists, alignment, margins, etc.)."""
125
+ nf = QTextCharFormat()
126
+ self.setCurrentCharFormat(nf)
127
+
128
+ def _break_anchor_for_next_char(self):
129
+ c = self.textCursor()
130
+ fmt = c.charFormat()
131
+ if fmt.isAnchor() or fmt.fontUnderline() or fmt.foreground().style() != 0:
132
+ # clone, then strip just the link-specific bits so the next char is plain text
133
+ nf = QTextCharFormat(fmt)
134
+ nf.setAnchor(False)
135
+ nf.setFontUnderline(False)
136
+ nf.clearForeground()
137
+ try:
138
+ nf.setAnchorHref("")
139
+ except AttributeError:
140
+ nf.setAnchorNames([])
141
+ self.setCurrentCharFormat(nf)
142
+
143
+ def merge_on_sel(self, fmt):
144
+ """
145
+ Sets the styling on the selected characters.
146
+ """
147
+ cursor = self.textCursor()
148
+ if not cursor.hasSelection():
149
+ cursor.select(cursor.SelectionType.WordUnderCursor)
150
+ cursor.mergeCharFormat(fmt)
151
+ self.mergeCurrentCharFormat(fmt)
152
+
153
+ @Slot()
154
+ def apply_weight(self):
155
+ cur = self.currentCharFormat()
156
+ fmt = QTextCharFormat()
157
+ weight = (
158
+ QFont.Weight.Normal
159
+ if cur.fontWeight() == QFont.Weight.Bold
160
+ else QFont.Weight.Bold
161
+ )
162
+ fmt.setFontWeight(weight)
163
+ self.merge_on_sel(fmt)
164
+
165
+ @Slot()
166
+ def apply_italic(self):
167
+ cur = self.currentCharFormat()
168
+ fmt = QTextCharFormat()
169
+ fmt.setFontItalic(not cur.fontItalic())
170
+ self.merge_on_sel(fmt)
171
+
172
+ @Slot()
173
+ def apply_underline(self):
174
+ cur = self.currentCharFormat()
175
+ fmt = QTextCharFormat()
176
+ fmt.setFontUnderline(not cur.fontUnderline())
177
+ self.merge_on_sel(fmt)
178
+
179
+ @Slot()
180
+ def apply_strikethrough(self):
181
+ cur = self.currentCharFormat()
182
+ fmt = QTextCharFormat()
183
+ fmt.setFontStrikeOut(not cur.fontStrikeOut())
184
+ self.merge_on_sel(fmt)
185
+
186
+ @Slot()
187
+ def apply_code(self):
188
+ c = self.textCursor()
189
+ if not c.hasSelection():
190
+ c.select(c.SelectionType.BlockUnderCursor)
191
+
192
+ bf = QTextBlockFormat()
193
+ bf.setLeftMargin(12)
194
+ bf.setRightMargin(12)
195
+ bf.setTopMargin(6)
196
+ bf.setBottomMargin(6)
197
+ bf.setBackground(QColor(245, 245, 245))
198
+ bf.setNonBreakableLines(True)
199
+
200
+ cf = QTextCharFormat()
201
+ mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
202
+ cf.setFont(mono)
203
+ cf.setFontFixedPitch(True)
204
+
205
+ # If the current block already looks like a code block, remove styling
206
+ cur_bf = c.blockFormat()
207
+ is_code = (
208
+ cur_bf.nonBreakableLines()
209
+ and cur_bf.background().color().rgb() == QColor(245, 245, 245).rgb()
210
+ )
211
+ if is_code:
212
+ # clear: margins/background/wrapping
213
+ bf = QTextBlockFormat()
214
+ cf = QTextCharFormat()
215
+
216
+ c.mergeBlockFormat(bf)
217
+ c.mergeBlockCharFormat(cf)
218
+
219
+ @Slot(int)
220
+ def apply_heading(self, size):
221
+ fmt = QTextCharFormat()
222
+ if size:
223
+ fmt.setFontWeight(QFont.Weight.Bold)
224
+ fmt.setFontPointSize(size)
225
+ else:
226
+ fmt.setFontWeight(QFont.Weight.Normal)
227
+ fmt.setFontPointSize(self.font().pointSizeF())
228
+ self.merge_on_sel(fmt)
229
+
230
+ def toggle_bullets(self):
231
+ c = self.textCursor()
232
+ lst = c.currentList()
233
+ if lst and lst.format().style() == QTextListFormat.Style.ListDisc:
234
+ lst.remove(c.block())
235
+ return
236
+ fmt = QTextListFormat()
237
+ fmt.setStyle(QTextListFormat.Style.ListDisc)
238
+ c.createList(fmt)
239
+
240
+ def toggle_numbers(self):
241
+ c = self.textCursor()
242
+ lst = c.currentList()
243
+ if lst and lst.format().style() == QTextListFormat.Style.ListDecimal:
244
+ lst.remove(c.block())
245
+ return
246
+ fmt = QTextListFormat()
247
+ fmt.setStyle(QTextListFormat.Style.ListDecimal)
248
+ c.createList(fmt)
@@ -14,8 +14,8 @@ class KeyPrompt(QDialog):
14
14
  def __init__(
15
15
  self,
16
16
  parent=None,
17
- title: str = "Unlock database",
18
- message: str = "Enter SQLCipher key",
17
+ title: str = "Enter key",
18
+ message: str = "Enter key",
19
19
  ):
20
20
  super().__init__(parent)
21
21
  self.setWindowTitle(title)
@@ -11,5 +11,6 @@ def main():
11
11
  app = QApplication(sys.argv)
12
12
  app.setApplicationName(APP_NAME)
13
13
  app.setOrganizationName(APP_ORG)
14
- win = MainWindow(); win.show()
14
+ win = MainWindow()
15
+ win.show()
15
16
  sys.exit(app.exec())