Compare commits

..

10 Commits

Author SHA1 Message Date
Christopher T. Johnson
73a96e79a2 update requirements 2024-05-24 11:24:22 -04:00
Christopher T. Johnson
0acba3ed9b More progress on all entites in MW data load 2024-05-14 11:08:02 -04:00
Christopher T. Johnson
7c65b466f1 Mostly working! 2024-05-10 12:08:21 -04:00
Christopher T. Johnson
f97305e36e Mostly working clickables. 2024-05-08 14:20:32 -04:00
Christopher T. Johnson
7d2532d775 Layout is good, click boxes is wrong 2024-05-07 11:26:15 -04:00
Christopher T. Johnson
51b1121176 Lint 2024-04-16 11:50:26 -04:00
Christopher T. Johnson
f1ad24d70a Clean plugins too 2024-04-16 11:48:19 -04:00
Christopher T. Johnson
1bce000978 Lots of additions and fixes 2024-04-16 10:52:34 -04:00
Christopher T. Johnson
303dbe6fe0 Add subduedBackground 2024-04-16 10:51:57 -04:00
Christopher T. Johnson
51a924b510 Make SoundOff a true singleton 2024-04-16 10:51:08 -04:00
11 changed files with 1241 additions and 781 deletions

View File

@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
source venv/bin/activate source venv/bin/activate
set -e set -e
isort --profile black *.py lib/*.py isort --profile black *.py lib/*.py plugins/*.py
black -l 80 *.py lib/*.py black -l 80 *.py lib/*.py plugins/*.py
flynt *.py lib/*.py flynt *.py lib/*.py plugins/*.py
mypy esl_reader.py mypy esl_reader.py plugins/*.py

38
deftest.py Normal file → Executable file
View File

@@ -3,20 +3,36 @@ import faulthandler
import os import os
import signal import signal
import sys import sys
from typing import cast from typing import Any, cast
from PyQt6.QtCore import QResource, QSettings from PyQt6.QtCore import QResource, QSettings, Qt, pyqtSlot
from PyQt6.QtGui import QFontDatabase from PyQt6.QtGui import QFontDatabase
from PyQt6.QtSql import QSqlDatabase, QSqlQuery from PyQt6.QtSql import QSqlDatabase, QSqlQuery
from PyQt6.QtWidgets import QApplication from PyQt6.QtWidgets import QApplication, QScrollArea
from lib import DefinitionArea, Word from lib import Word
from lib.sounds import SoundOff from lib.sounds import SoundOff
from lib.utils import query_error from lib.utils import query_error
from lib.words import Definition from lib.words import Definition
class DefinitionArea(QScrollArea):
def __init__(self, w: Word, *args: Any, **kwargs: Any) -> None:
super(DefinitionArea, self).__init__(*args, *kwargs)
self.definition = Definition(w)
self.setWidget(self.definition)
self.setWidgetResizable(True)
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
self.definition.newWord.connect(self.newWord)
return
def monkeyClose(self, event): @pyqtSlot(str)
def newWord(self, word:str) -> None:
print(f"newWord: {word}")
w = Word(word)
self.definition.setWord(w)
return
def closeEvent(self, event):
settings = QSettings("Troglodite", "esl_reader") settings = QSettings("Troglodite", "esl_reader")
settings.setValue("geometry", self.saveGeometry()) settings.setValue("geometry", self.saveGeometry())
super(DefinitionArea, self).closeEvent(event) super(DefinitionArea, self).closeEvent(event)
@@ -66,12 +82,15 @@ def main() -> int:
): ):
query_error(query) query_error(query)
word = Word("cowbell") word = Word("lower")
snd = SoundOff() snd = SoundOff()
DefinitionArea.closeEvent = monkeyClose print("Pre widget")
widget = DefinitionArea(word) # xnoqa: F841 widget = DefinitionArea(word) # xnoqa: F841
print("post widget")
settings = QSettings("Troglodite", "esl_reader") settings = QSettings("Troglodite", "esl_reader")
widget.restoreGeometry(settings.value("geometry")) geometry = settings.value("geometry")
if geometry is not None:
widget.restoreGeometry(geometry)
d = cast(Definition, widget.widget()) d = cast(Definition, widget.widget())
assert d is not None assert d is not None
d.pronounce.connect(snd.playSound) d.pronounce.connect(snd.playSound)
@@ -81,4 +100,7 @@ def main() -> int:
if __name__ == "__main__": if __name__ == "__main__":
faulthandler.register(signal.Signals.SIGUSR1) faulthandler.register(signal.Signals.SIGUSR1)
faulthandler.register(signal.Signals.SIGTERM)
faulthandler.register(signal.Signals.SIGHUP)
faulthandler.enable()
sys.exit(main()) sys.exit(main())

View File

@@ -1,8 +1,8 @@
# pyright: ignore # pyright: ignore
from .utils import query_error # isort: skip from .utils import query_error # isort: skip
from .books import Book from .books import Book
from .definition import Definition, Fragment, Line
from .person import PersonDialog from .person import PersonDialog
from .read import ReadDialog from .read import ReadDialog
from .session import SessionDialog from .session import SessionDialog
from .words import DefinitionArea, Word from .words import Word
from .definition import Fragment, Line, Definition

View File

@@ -1,17 +1,39 @@
import re import unicodedata
from typing import Any, Optional, Self, cast, overload from typing import Any, Callable, Optional, Self, TypedDict, cast
import re
from PyQt6.QtCore import QMargins, QPoint, QRect, QSize, QUrl, Qt, pyqtSignal from PyQt6.QtCore import QMargins, QPoint, QPointF, QRect, QRectF, QSize, Qt, QUrl, pyqtSignal
from PyQt6.QtGui import QColor, QFont, QFontMetrics, QMouseEvent, QPaintEvent, QPainter, QResizeEvent, QTextOption, QTransform, QBrush from PyQt6.QtGui import (
QBrush,
QColor,
QFont,
QFontDatabase,
QFontMetrics,
QMouseEvent,
QPainter,
QPaintEvent,
QResizeEvent,
QTextCharFormat,
QTextLayout,
QTextOption,
)
from lib.sounds import SoundOff
from PyQt6.QtWidgets import QWidget from PyQt6.QtWidgets import QWidget
class MyPointF(QPointF):
def __repr__(self):
return f"({self.x()}, {self.y()})"
def __str__(self):
return self.__repr__()
class Fragment: class Fragment:
"""A fragment of text to be displayed""" """A fragment of text to be displayed"""
_indentAmount = 35 _indentAmount = 35
def __init__( def __init__(
self, self,
which: str|Self, which: str | Self | None = None,
font: QFont | None = None, font: QFont | None = None,
audio: str = "", audio: str = "",
color: Optional[QColor] = None, color: Optional[QColor] = None,
@@ -21,19 +43,22 @@ class Fragment:
for k, v in which.__dict__.items(): for k, v in which.__dict__.items():
self.__dict__[k] = v self.__dict__[k] = v
return return
self._text:str = which self._layout = QTextLayout()
if font is None: if font is None:
raise TypeError("Missing required parameter 'font'") self._layout.setFont(
self._font = font QFontDatabase.font("OpenDyslexic", None, 20)
self._audio: QUrl = QUrl(audio) )
self._align = QTextOption( else:
self._layout.setFont(font)
align = QTextOption(
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBaseline Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBaseline
) )
self._layout.setTextOption(align)
self._audio: QUrl = QUrl(audio)
self._padding = QMargins() self._padding = QMargins()
self._border = QMargins() self._border = QMargins()
self._margin = QMargins() self._margin = QMargins()
self._wref = "" self._wref = ""
self._position = QPoint()
self._rect = QRect() self._rect = QRect()
self._borderRect = QRect() self._borderRect = QRect()
self._clickRect = QRect() self._clickRect = QRect()
@@ -44,147 +69,114 @@ class Fragment:
self._background = QColor() self._background = QColor()
self._asis = asis self._asis = asis
self._indent = 0 self._indent = 0
self._target = "word" if which is not None:
self.setText(which)
return return
def __str__(self) -> str: def size(self) -> QSize:
return self.__repr__() return self.paintEvent()
def size(self, width: int) -> QSize: def height(self) -> int:
return self.paintEvent(width) return self.size().height()
def height(self, width: int) -> int: def width(self) -> int:
return self.size(width).height() return self.size().width()
def width(self, width: int) -> int:
return self.size(width).width()
def __repr__(self) -> str: def __repr__(self) -> str:
return f"({self._position.x()}, {self._position.y()}): {self._text}" rect = self._layout.boundingRect()
text = self._layout.text()
return f"{text}: (({rect.x()}, {rect.y()}), {rect.width()}, {rect.height()})"
@overload def doLayout(self, width: int) -> QPointF:
def paintEvent(self, widthSrc:int) -> QSize: leading = QFontMetrics(self._layout.font()).leading()
... eol = self._layout.position()
@overload base = 0
def paintEvent(self, widthSrc: QPainter) -> int: indent = 0
... self._layout.setCacheEnabled(True)
self._layout.beginLayout()
while True:
line = self._layout.createLine()
if not line.isValid():
break
line.setLineWidth(width - self._layout.position().x())
line.setPosition(QPointF(indent, base+leading))
rect = line.naturalTextRect()
eol = rect.bottomRight()
assert isinstance(eol, QPointF)
base += line.height()
indent = self.pixelIndent() - self._layout.position().x()
self._layout.endLayout()
result = eol
return result
def paintEvent(self, widthSrc) -> int|QSize: def paintEvent(self, painter: Optional[QPainter] | None = None) -> QSize:
if isinstance(widthSrc, QPainter): rect = self._layout.boundingRect()
viewportWidth = widthSrc.viewport().width() size = rect.size()
painter = widthSrc assert size is not None
else:
viewportWidth = widthSrc
painter = None
fm = QFontMetrics(self._font)
top = (
self._position.y()
+ fm.descent()
- fm.height()
)
left = self._position.x()
width = viewportWidth - left
height = 2000
rect = QRect(left, top, width, height)
indent = self._indent * self._indentAmount
flags = (
Qt.AlignmentFlag.AlignLeft
| Qt.AlignmentFlag.AlignBaseline
)
boundingNoWrap = fm.boundingRect(
rect, flags|Qt.TextFlag.TextSingleLine, self._text
)
bounding = fm.boundingRect(
rect, flags|Qt.TextFlag.TextWordWrap, self._text
)
text = self._text
remainingText = ''
if boundingNoWrap.height() < bounding.height():
#
# This is not optimal, but it is only a few iterations
#
lastSpace = 0
char = 0
pos = rect.x()
while pos < rect.right():
if text[char] == ' ':
lastSpace = char
pos += fm.horizontalAdvance(
text[char]
)
char += 1
if lastSpace > 0:
remainingText = text[lastSpace+1:]
text = text[:lastSpace]
size = boundingNoWrap.size()
boundingNoWrap = fm.boundingRect(
rect, flags|Qt.TextFlag.TextSingleLine, text
)
rect.setSize(boundingNoWrap.size())
if remainingText != '':
top += size.height()
remainingRect = QRect(
indent, top,
viewportWidth - indent, height
)
boundingRemaingRect = fm.boundingRect(
remainingRect, flags | Qt.TextFlag.TextWordWrap, remainingText
)
size = size.grownBy(
QMargins(
0,0,0, boundingRemaingRect.height()
)
)
remainingRect.setSize(boundingRemaingRect.size())
size = size.grownBy(self._margin)
size = size.grownBy(self._border)
size = size.grownBy(self._padding)
if painter is None: if painter is None:
return size return QSize(int(size.width()), int(size.height()))
painter.save() painter.save()
painter.setFont(self._font) self._layout.draw(painter, QPointF(0,0))
painter.setPen(QColor("#f00")) #
if self._audio.isValid(): # TODO: draw the rounded rect around audio buttons
radius = self._borderRect.height() / 2 #
painter.drawRoundedRect(self._borderRect, radius, radius) painter.brush().setColor(Qt.GlobalColor.green)
if self._wref: for fmt in self._layout.formats():
start = bounding.bottomLeft() if fmt.format.isAnchor():
end = bounding.bottomRight() runs = self._layout.glyphRuns(fmt.start, fmt.length)
painter.drawLine(start, end) bb = runs[0].boundingRect()
bb.moveTo(bb.topLeft() + self._layout.position())
url = QUrl(fmt.format.anchorHref())
if url.scheme() == 'audio':
painter.setPen(QColor('red'))
radius = (bb.topLeft() - bb.bottomLeft()).manhattanLength()/4
painter.drawRoundedRect(bb, radius,radius)
else:
painter.setPen(QColor("blue"))
#painter.drawRect(bb)
painter.setPen(self._color)
if self._background.isValid():
brush = painter.brush()
brush.setColor(self._background)
brush.setStyle(Qt.BrushStyle.SolidPattern)
painter.setBrush(brush)
painter.fillRect(rect,brush)
painter.drawText(rect, flags, text)
if remainingText:
if self._background.isValid():
painter.fillRect(remainingRect, brush)
painter.drawText(remainingRect, flags|Qt.TextFlag.TextWordWrap, remainingText)
painter.restore() painter.restore()
return size.height() return QSize(int(size.width()), int(size.height()))
# #
# Setters # Setters
# #
def setText(self, text: str) -> None: def addText(self, text: str, fmt: Optional[QTextCharFormat] = None) -> None:
self._text = text oldText = self._layout.text()
self._layout.setText(oldText + text)
if Line.parseText:
self._layout = Line.parseText(self)
if fmt is not None:
fr = QTextLayout.FormatRange()
fr.format = fmt
fr.length = len(self._layout.text()) - len(oldText)
fr.start = len(oldText)
fmts = self._layout.formats()
fmts.append(fr)
self._layout.setFormats(fmts)
return return
def setTarget(self, target: str) -> None: def setText(self, text: str) -> None:
self._target = target text = unicodedata.normalize("NFKD",text)
self._layout.setText(text)
if Line.parseText:
self._layout = Line.parseText(self)
if self.audio().isValid():
fr = QTextLayout.FormatRange()
fr.start=0
fr.length = len(self._layout.text())
fmt = QTextCharFormat()
fmt.setAnchor(True)
fmt.setAnchorHref(self._audio.toString())
fr.format = fmt
formats = self._layout.formats()
formats.append(fr)
self._layout.setFormats(formats)
return return
def setFont(self, font: QFont) -> None: def setFont(self, font: QFont) -> None:
self._font = font self._layout.setFont(font)
return return
def setAudio(self, audio: str | QUrl) -> None: def setAudio(self, audio: str | QUrl) -> None:
@@ -195,7 +187,7 @@ class Fragment:
return return
def setAlign(self, align: QTextOption) -> None: def setAlign(self, align: QTextOption) -> None:
self._align = align self._layout.setTextOption(align)
return return
def setRect(self, rect: QRect) -> None: def setRect(self, rect: QRect) -> None:
@@ -292,7 +284,7 @@ class Fragment:
return return
def setPosition(self, pnt: QPoint) -> None: def setPosition(self, pnt: QPoint) -> None:
self._position = pnt self._layout.setPosition(QPointF(pnt.x(), pnt.y()))
return return
def setBorderRect(self, rect: QRect) -> None: def setBorderRect(self, rect: QRect) -> None:
@@ -310,6 +302,7 @@ class Fragment:
def setBackground(self, color: QColor) -> None: def setBackground(self, color: QColor) -> None:
self._background = color self._background = color
return return
def setIndent(self, indent: int) -> None: def setIndent(self, indent: int) -> None:
self._indent = indent self._indent = indent
return return
@@ -317,20 +310,23 @@ class Fragment:
# #
# Getters # Getters
# #
def background(self) -> QColor:
return self._background
def wRef(self) -> str: def wRef(self) -> str:
return self._wref return self._wref
def text(self) -> str: def text(self) -> str:
return self._text return self._layout.text()
def font(self) -> QFont: def font(self) -> QFont:
return self._font return self._layout.font()
def audio(self) -> QUrl: def audio(self) -> QUrl:
return self._audio return self._audio
def align(self) -> QTextOption: def align(self) -> QTextOption:
return self._align return self._layout.textOption()
def rect(self) -> QRect: def rect(self) -> QRect:
return self._rect return self._rect
@@ -344,8 +340,8 @@ class Fragment:
def margin(self) -> QMargins: def margin(self) -> QMargins:
return self._margin return self._margin
def position(self) -> QPoint: def position(self) -> QPointF:
return self._position return self._layout.position()
def borderRect(self) -> QRect: def borderRect(self) -> QRect:
return self._borderRect return self._borderRect
@@ -365,8 +361,12 @@ class Fragment:
def pixelIndent(self) -> int: def pixelIndent(self) -> int:
return self._indent * self._indentAmount return self._indent * self._indentAmount
def layout(self) -> QTextLayout:
return self._layout
class Line: class Line:
parseText = None parseText = None
def __init__(self) -> None: def __init__(self) -> None:
self._maxHeight = -1 self._maxHeight = -1
self._baseLine = -1 self._baseLine = -1
@@ -379,8 +379,9 @@ class Line:
"|".join([x.text() for x in self._fragments]) "|".join([x.text() for x in self._fragments])
+ f"|{self._maxHeight}" + f"|{self._maxHeight}"
) )
@classmethod @classmethod
def setParseText(cls, call) -> None: def setParseText(cls, call: Callable) -> None:
cls.parseText = call cls.parseText = call
return return
@@ -388,95 +389,48 @@ class Line:
# #
# we do not have an event field because we are not a true widget # we do not have an event field because we are not a true widget
# #
lineSpacing = 0 pos = QSize(0,0)
for frag in self._fragments: for frag in self._fragments:
ls = frag.paintEvent(painter) pos = frag.paintEvent(painter)
if ls > lineSpacing: return pos.height()
lineSpacing = ls
return lineSpacing
def addFragment(
def addFragment(self, frags: Fragment|list[Fragment],) -> None: self,
SPEAKER = "\U0001F508" frags: Fragment | list[Fragment],
) -> None:
#SPEAKER = "\U0001F508"
if not isinstance(frags, list): if not isinstance(frags, list):
frags = [frags, ] frags = [
for frag in frags: frags,
if frag.audio().isValid(): ]
frag.setText(frag.text() + " " + SPEAKER) self._fragments += frags
text = frag.text()
text = re.sub(r"\*", "\u2022", text)
text = re.sub(r"\{ldquo\}", "\u201c", text)
text = re.sub(r"\{rdquo\}", "\u201d", text)
frag.setText(text)
if frag.audio().isValid():
frag.setPadding(3, 0, 0, 5)
frag.setBorder(1)
frag.setMargin(0, 0, 0, 0)
if Line.parseText:
items = Line.parseText(frag)
self._fragments += items
else:
self._fragments.append(frag)
return return
def finalizeLine(self, width: int, base: int) -> None: def finalizeLine(self, width: int, base: int) -> None:
"""Create all of the positions for all the fragments.""" """Create all of the positions for all the fragments."""
# #
# Find the maximum hight and max baseline # Each fragment needs to be positioned to the left of the
# last fragment or at the indent level.
# It needs to be aligned with the baseline of all the
# other fragments in the line.
# #
maxHeight = -1
baseLine = -1 left = 0 # Left size of rect
leading = -1 maxHeight = 0
for frag in self._fragments:
fm = QFontMetrics(frag.font())
height = frag.height(width)
bl = fm.height() - fm.descent()
if fm.leading() > leading:
leading = fm.leading()
if height > maxHeight:
maxHeight = height
if bl > baseLine:
baseLine = bl
self._baseLine = baseLine
self._maxHeight = maxHeight
self._leading = leading
x = 0
for frag in self._fragments: for frag in self._fragments:
if left < frag.pixelIndent():
left = frag.pixelIndent() left = frag.pixelIndent()
if x < left: frag.setPosition(QPoint(left, base))
x = left eol =frag.doLayout(width)
# left = int(eol.x()+0.5)
# We need to calculate the location to draw the if frag.layout().lineCount() > 1:
# text. We also need to calculate the bounding Rectangle base = int(eol.y()+0.5)
# for this fragment if eol.y() > maxHeight:
# maxHeight = eol.y()
size = frag.size(width) self._maxHeight = int(maxHeight+0.5)
fm = QFontMetrics(frag.font()) self._leading = 0
offset = (
frag.margin().left()
+ frag.border().left()
+ frag.padding().left()
)
frag.setPosition(QPoint(x + offset, self._baseLine))
if not frag.border().isNull() or not frag.wRef():
#
# self._baseLine is where the text will be drawn
# fm.descent is the distance from the baseline of the
# text to the bottom of the rect
# The top of the bounding rect is at self._baseLine
# + fm.descent - rect.height
# The border is drawn at top-padding-border-margin+marin
#
top = self._baseLine + fm.descent() - fm.height()
y = top - frag.padding().top() - frag.border().top()
pos = QPoint(x, y)
rect = QRect(pos, size.shrunkBy(frag.margin()))
frag.setBorderRect(rect)
pos.setY(pos.y() + base)
frag.setClickRect(QRect(pos, size.shrunkBy(frag.margin())))
x += size.width()
return return
def getLine(self) -> list[Fragment]: def getLine(self) -> list[Fragment]:
@@ -485,13 +439,22 @@ class Line:
def getLineSpacing(self) -> int: def getLineSpacing(self) -> int:
return self._leading + self._maxHeight return self._leading + self._maxHeight
class Definition(QWidget): class Clickable(TypedDict):
pronounce = pyqtSignal(str) bb: QRectF
frag: Fragment
fmt: QTextCharFormat
class Definition(QWidget):
pronounce = pyqtSignal(QUrl)
alert = pyqtSignal()
newWord = pyqtSignal(str)
def __init__( def __init__(
self, word: Optional[Any] = None, *args: Any, **kwargs: Any self, word: Optional[Any] = None, *args: Any, **kwargs: Any
) -> None: ) -> None:
super(Definition, self).__init__(*args, **kwargs) super(Definition, self).__init__(*args, **kwargs)
self._sound = SoundOff()
self.pronounce.connect(self._sound.playSound)
self.alert.connect(self._sound.alert)
self._word = word self._word = word
if word is not None: if word is not None:
self.setWord(word) self.setWord(word)
@@ -502,18 +465,12 @@ class Definition(QWidget):
lines: list[Line] = word.get_def() lines: list[Line] = word.get_def()
assert lines is not None assert lines is not None
self._lines = lines self._lines = lines
self._buttons: list[Fragment] = []
base = 0 base = 0
for line in self._lines: for line in self._lines:
line.finalizeLine(self.width(), base) line.finalizeLine(self.width(), base)
for frag in line.getLine():
if frag.audio().isValid():
self._buttons.append(frag)
if frag.wRef():
print(f"Adding {frag} as an anchor")
self._buttons.append(frag)
base += line.getLineSpacing() base += line.getLineSpacing()
self.setFixedHeight(base) self.setFixedHeight(base)
return return
@@ -526,40 +483,58 @@ class Definition(QWidget):
super(Definition, self).resizeEvent(event) super(Definition, self).resizeEvent(event)
return return
_downFrag: Optional[Fragment | None] = None _downClickable: Optional[Clickable] = None
def mousePressEvent(self, event: Optional[QMouseEvent]) -> None: def mousePressEvent(self, event: Optional[QMouseEvent]) -> None:
if not event: if not event:
return super().mousePressEvent(event) return super().mousePressEvent(event)
print(f"mousePressEvent: {event.pos()}") position = MyPointF(event.position())
for frag in self._buttons: print(f"mousePressEvent: {position}")
rect = frag.clickRect() for line in self._lines:
if rect.contains(event.pos()): for frag in line.getLine():
self._downFrag = frag layout = frag.layout()
return for fmtRng in layout.formats():
if fmtRng.format.isAnchor():
runs = layout.glyphRuns(fmtRng.start, fmtRng.length)
assert len(runs) == 1
bb = runs[0].boundingRect()
bb.moveTo(bb.topLeft() + layout.position())
if bb.contains(event.position()):
self._downClickable = {
'bb': bb,
'fmt': fmtRng.format,
'frag': frag,
}
return super().mousePressEvent(event) return super().mousePressEvent(event)
def mouseReleaseEvent(self, event: Optional[QMouseEvent]) -> None: def mouseReleaseEvent(self, event: Optional[QMouseEvent]) -> None:
if not event: if not event:
return super().mouseReleaseEvent(event) return super().mouseReleaseEvent(event)
if self._downFrag is not None and self._downFrag.clickRect().contains( if (self._downClickable is not None and
event.pos() self._downClickable["bb"].contains(event.position())
): ):
audio = self._downFrag.audio().url() print(f"mousePressPseudoEvent: {event.position()}")
print(audio) clk = self._downClickable
self.pronounce.emit(audio) url = QUrl(clk['fmt'].anchorHref())
print("emit done") if url.scheme() == 'audio':
self._downFrag = None url.setScheme('https')
self.pronounce.emit(url)
elif url.scheme() == 'word':
self.newWord.emit(url.path())
elif url.scheme() == 'sense':
self.newWord.emit(url.path())
else:
print(f"{clk['fmt'].anchorHref()}")
self.alert.emit()
self._downClickable = None
return return
self._downFrag = None self._downClickable = None
return super().mouseReleaseEvent(event) return super().mouseReleaseEvent(event)
def paintEvent(self, _: Optional[QPaintEvent]) -> None: # noqa def paintEvent(self, _: Optional[QPaintEvent]) -> None: # noqa
painter = QPainter(self) painter = QPainter(self)
painter.save()
painter.setBrush(QBrush()) painter.setBrush(QBrush())
painter.setPen(QColor("white")) painter.setPen(QColor("white"))
# #
# Each line needs a base calculated. To do that, we need to find the # Each line needs a base calculated. To do that, we need to find the
# bounding rectangle of the text. Once we have the bounding rectangle, # bounding rectangle of the text. Once we have the bounding rectangle,
@@ -569,11 +544,6 @@ class Definition(QWidget):
# All text on this line needs to be on the same baseline # All text on this line needs to be on the same baseline
# #
assert self._lines is not None assert self._lines is not None
base = 0
for line in self._lines: for line in self._lines:
transform = QTransform() line.paintEvent(painter)
transform.translate(0, base)
painter.setTransform(transform)
base += line.paintEvent(painter)
painter.restore()
return return

View File

@@ -1,12 +1,9 @@
import json from typing import Dict, List, Optional, cast
from typing import Any, Dict, List, Optional, cast
import requests
from PyQt6.QtCore import QPoint, QResource, Qt, QTimer, pyqtSignal, pyqtSlot from PyQt6.QtCore import QPoint, QResource, Qt, QTimer, pyqtSignal, pyqtSlot
from PyQt6.QtGui import ( from PyQt6.QtGui import (
QBrush, QBrush,
QColor, QColor,
QCursor,
QKeyEvent, QKeyEvent,
QPainter, QPainter,
QPainterPath, QPainterPath,
@@ -15,7 +12,7 @@ from PyQt6.QtGui import (
QTextCursor, QTextCursor,
) )
from PyQt6.QtSql import QSqlQuery from PyQt6.QtSql import QSqlQuery
from PyQt6.QtWidgets import QDialog, QTextEdit, QWidget from PyQt6.QtWidgets import QDialog, QWidget
from lib import query_error from lib import query_error
from lib.preferences import Preferences from lib.preferences import Preferences
@@ -89,6 +86,7 @@ class ReadDialog(QDialog, Ui_ReadDialog):
self.playSound.connect(self.sound.playSound) self.playSound.connect(self.sound.playSound)
self.playAlert.connect(self.sound.alert) self.playAlert.connect(self.sound.alert)
self.definition.pronounce.connect(self.sound.playSound) self.definition.pronounce.connect(self.sound.playSound)
self.definition.newWord.connect(self.newWord)
return return
# #
@@ -98,6 +96,15 @@ class ReadDialog(QDialog, Ui_ReadDialog):
# #
# slots # slots
# #
@pyqtSlot(str)
def newWord(self, word: str) -> None:
w = Word(word)
if not w.isValid():
self.playAlert.emit()
return
self.definition.setWord(w)
return
@pyqtSlot() @pyqtSlot()
def timerAction(self) -> None: def timerAction(self) -> None:
if self.session.isActive(): # We are stopping if self.session.isActive(): # We are stopping
@@ -127,6 +134,9 @@ class ReadDialog(QDialog, Ui_ReadDialog):
cursor.select(QTextCursor.SelectionType.WordUnderCursor) cursor.select(QTextCursor.SelectionType.WordUnderCursor)
text = cursor.selectedText().strip() text = cursor.selectedText().strip()
word = Word(text) word = Word(text)
if not word.isValid():
self.playAlert.emit()
return
word.playPRS() word.playPRS()
return return
@@ -221,6 +231,9 @@ class ReadDialog(QDialog, Ui_ReadDialog):
cursor.select(cursor.SelectionType.WordUnderCursor) cursor.select(cursor.SelectionType.WordUnderCursor)
text = cursor.selectedText().strip() text = cursor.selectedText().strip()
word = Word(text) word = Word(text)
if not word.isValid():
self.playAlert.emit()
return
self.definition.setWord(word) self.definition.setWord(word)
self.showDefinition() self.showDefinition()
return return

View File

@@ -22,6 +22,7 @@ from PyQt6.QtNetwork import (
QNetworkReply, QNetworkReply,
QNetworkRequest, QNetworkRequest,
) )
from trycast import trycast
# from PyQt6.QtWidgets import QWidget # from PyQt6.QtWidgets import QWidget
@@ -33,39 +34,53 @@ class SoundOff(QObject):
if cls._instance: if cls._instance:
return cls._instance return cls._instance
cls._instance = super(SoundOff, cls).__new__(cls) cls._instance = super(SoundOff, cls).__new__(cls)
#
# Setup devices
#
cls.virtualDevice = None
for output in QMediaDevices.audioOutputs():
if output.id().data().decode("utf-8") == "virt-input":
cls.virtualDevice = output
if output.isDefault():
cls.localDevice = output
cls.alertEffect = QSoundEffect()
cls.alertEffect.setSource(QUrl("qrc:/beep.wav"))
cls.alertEffect.setAudioDevice(cls.localDevice)
cls.alertEffect.setVolume(0.25)
cls.alertEffect.setLoopCount(1)
cls.localPlayer = QMediaPlayer()
cls.localPlayer.setObjectName("localPlayer")
cls.localOutput = QAudioOutput()
cls.localOutput.setDevice(cls.localDevice)
cls.localPlayer.setAudioOutput(cls.localOutput)
if cls.virtualDevice:
cls.virtualPlayer = QMediaPlayer()
cls.virtualPlayer.setObjectName("virtualPlayer")
cls.virtualOutput = QAudioOutput()
cls.virtualOutput.setVolume(1.0)
cls.virtualOutput.setDevice(cls.virtualDevice)
cls.virtualPlayer.setAudioOutput(cls.virtualOutput)
cacheDir = QDir(
QStandardPaths.writableLocation(
QStandardPaths.StandardLocation.GenericCacheLocation
)
)
cacheDir.mkdir("Troglodite")
cacheDir = QDir(cacheDir.path() + QDir.separator() + "Troglodite")
netCache = QNetworkDiskCache()
netCache.setCacheDirectory(cacheDir.path())
cls.nam = QNetworkAccessManager()
cls.nam.setCache(netCache)
return cls._instance return cls._instance
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
# if self.localPlayer.receivers(self.localPlayer.errorOccurred) > 0:
# Setup devices print("SoundOff, __init__() after __init__()")
#
self.virtualDevice = None
for output in QMediaDevices.audioOutputs():
if output.id().data().decode("utf-8") == "virt-input":
self.virtualDevice = output
if output.isDefault():
self.localDevice = output
self.alertEffect = QSoundEffect()
self.alertEffect.setSource(QUrl("qrc:/beep.wav"))
self.alertEffect.setAudioDevice(self.localDevice)
self.alertEffect.setVolume(0.25)
self.alertEffect.setLoopCount(1)
self.localPlayer = QMediaPlayer()
self.localPlayer.setObjectName("localPlayer")
self.localOutput = QAudioOutput()
self.localOutput.setDevice(self.localDevice)
self.localPlayer.setAudioOutput(self.localOutput)
if self.virtualDevice:
self.virtualPlayer = QMediaPlayer()
self.virtualPlayer.setObjectName("virtualPlayer")
self.virtualOutput = QAudioOutput()
self.virtualOutput.setVolume(1.0)
self.virtualOutput.setDevice(self.virtualDevice)
self.virtualPlayer.setAudioOutput(self.virtualOutput)
# #
# Connections # Connections
# #
@@ -76,18 +91,6 @@ class SoundOff(QObject):
self.virtualPlayer.errorOccurred.connect(self.mediaError) self.virtualPlayer.errorOccurred.connect(self.mediaError)
self.virtualPlayer.mediaStatusChanged.connect(self.mediaStatus) self.virtualPlayer.mediaStatusChanged.connect(self.mediaStatus)
self.virtualPlayer.playbackStateChanged.connect(self.playbackState) self.virtualPlayer.playbackStateChanged.connect(self.playbackState)
cacheDir = QDir(
QStandardPaths.writableLocation(
QStandardPaths.StandardLocation.GenericCacheLocation
)
)
cacheDir.mkdir("Troglodite")
cacheDir = QDir(cacheDir.path() + QDir.separator() + "Troglodite")
netCache = QNetworkDiskCache()
netCache.setCacheDirectory(cacheDir.path())
self.nam = QNetworkAccessManager()
self.nam.setCache(netCache)
self.nam.finished.connect(self.finished)
return return
@pyqtSlot(QMediaPlayer.Error, str) @pyqtSlot(QMediaPlayer.Error, str)
@@ -98,11 +101,13 @@ class SoundOff(QObject):
@pyqtSlot(QMediaPlayer.MediaStatus) @pyqtSlot(QMediaPlayer.MediaStatus)
def mediaStatus(self, status: QMediaPlayer.MediaStatus) -> None: def mediaStatus(self, status: QMediaPlayer.MediaStatus) -> None:
# print(f"mediaStatus: {status}")
if status == QMediaPlayer.MediaStatus.LoadedMedia: if status == QMediaPlayer.MediaStatus.LoadedMedia:
player: Optional[QMediaPlayer] = cast(QMediaPlayer, self.sender()) player = trycast(QMediaPlayer, self.sender())
if player is None:
player = trycast(QMediaPlayer, self.lastSender)
assert player is not None assert player is not None
player.play() player.play()
self.lastSender = self.sender()
return return
@pyqtSlot(QMediaPlayer.PlaybackState) @pyqtSlot(QMediaPlayer.PlaybackState)
@@ -119,6 +124,7 @@ class SoundOff(QObject):
return return
@pyqtSlot(str) @pyqtSlot(str)
@pyqtSlot(QUrl)
def playSound(self, url: str | QUrl) -> None: def playSound(self, url: str | QUrl) -> None:
if isinstance(url, str): if isinstance(url, str):
url = QUrl(url) url = QUrl(url)
@@ -128,8 +134,10 @@ class SoundOff(QObject):
self.virtualPlayer.setAudioOutput(self.virtualOutput) self.virtualPlayer.setAudioOutput(self.virtualOutput)
if url != self._lastUrl: if url != self._lastUrl:
request = QNetworkRequest(url) request = QNetworkRequest(url)
self.nam.get(request) reply = self.nam.get(request)
assert reply is not None
self._lastUrl = url self._lastUrl = url
reply.finished.connect(self.finished)
return return
for player in [self.localPlayer, self.virtualPlayer]: for player in [self.localPlayer, self.virtualPlayer]:
if not player: if not player:
@@ -151,20 +159,22 @@ class SoundOff(QObject):
_buffer: dict[QMediaPlayer, QBuffer] = {} _buffer: dict[QMediaPlayer, QBuffer] = {}
_lastUrl = QUrl() _lastUrl = QUrl()
@pyqtSlot(QNetworkReply) @pyqtSlot()
def finished(self, reply: QNetworkReply) -> None: def finished(self) -> None:
storage = reply.readAll() reply = trycast(QNetworkReply, self.sender())
assert reply is not None
crypto = QCryptographicHash(QCryptographicHash.Algorithm.Sha256) code = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
print(f"HttpStatusCodeAttribute: {code}, error: {reply.error()}")
self._reply = reply.readAll()
url = reply.request().url()
reply.close()
if self._reply.isEmpty() or self._reply.isNull():
return
for player in [self.localPlayer, self.virtualPlayer]: for player in [self.localPlayer, self.virtualPlayer]:
if not player: if not player:
continue continue
self._storage[player] = QByteArray(storage) self._storage[player] = QByteArray(self._reply)
crypto.addData(self._storage[player])
print(player, crypto.result().toHex())
crypto.reset()
self._buffer[player] = QBuffer(self._storage[player]) self._buffer[player] = QBuffer(self._storage[player])
url = reply.request().url()
player.setSourceDevice(self._buffer[player], url) player.setSourceDevice(self._buffer[player], url)
player.setPosition(0) player.setPosition(0)
if player.mediaStatus() == QMediaPlayer.MediaStatus.LoadedMedia: if player.mediaStatus() == QMediaPlayer.MediaStatus.LoadedMedia:

View File

@@ -2,7 +2,7 @@
from typing import NoReturn, Self from typing import NoReturn, Self
from PyQt6.QtCore import QCoreApplication, QDir, QStandardPaths, Qt from PyQt6.QtCore import QCoreApplication, QDir, QStandardPaths, Qt
from PyQt6.QtGui import QColor, QFont, QFontDatabase from PyQt6.QtGui import QColor, QFont, QFontDatabase, QTextCharFormat
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkDiskCache from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkDiskCache
from PyQt6.QtSql import QSqlQuery from PyQt6.QtSql import QSqlQuery
@@ -22,6 +22,7 @@ def query_error(query: QSqlQuery) -> NoReturn:
) )
raise Exception(translate("MainWindow", "SQL Error")) raise Exception(translate("MainWindow", "SQL Error"))
class Resources: class Resources:
_instance = None _instance = None
nam = QNetworkAccessManager() nam = QNetworkAccessManager()
@@ -38,11 +39,60 @@ class Resources:
linkColor: QColor linkColor: QColor
subduedColor: QColor subduedColor: QColor
subduedBackground: QColor
headerFormat = QTextCharFormat()
labelFormat = QTextCharFormat()
subduedFormat = QTextCharFormat()
subduedItalicFormat = QTextCharFormat()
sOnSFormat = QTextCharFormat()
subduedLabelFormat = QTextCharFormat()
phonticFormat = QTextCharFormat()
boldFormat = QTextCharFormat()
boldOnSFormat = QTextCharFormat()
italicFormat = QTextCharFormat()
textFormat = QTextCharFormat()
smallCapsFormat = QTextCharFormat()
def __new__(cls: type[Self]) -> Self: def __new__(cls: type[Self]) -> Self:
if cls._instance: if cls._instance:
return cls._instance return cls._instance
cls._instance = super(Resources, cls).__new__(cls) cls._instance = super(Resources, cls).__new__(cls)
# #
# colors
#
cls.baseColor = QColor(Qt.GlobalColor.white)
cls.linkColor = QColor("#4a7d95")
cls.subduedColor = QColor(Qt.GlobalColor.gray)
cls.subduedBackground = QColor("#444")
#
# Formats
#
LARGE = 36
MEDIUM = 22
SMALL = 18
cls.headerFormat.setFontPointSize(LARGE)
cls.labelFormat.setFontPointSize(MEDIUM)
cls.sOnSFormat.setForeground(cls.subduedColor)
#cls.sOnSFormat.setBackground(cls.subduedBackground)
cls.sOnSFormat.setFontPointSize(SMALL)
cls.subduedFormat.setForeground(cls.subduedColor)
cls.subduedFormat.setFontPointSize(SMALL)
cls.subduedLabelFormat.setForeground(cls.subduedColor)
cls.subduedLabelFormat.setFontPointSize(SMALL)
cls.phonticFormat.setFont(QFontDatabase.font("Gentium", None,20))
cls.phonticFormat.setFontPointSize(SMALL)
cls.boldFormat.setFontWeight(QFont.Weight.Bold)
cls.boldFormat.setFontPointSize(SMALL)
cls.boldOnSFormat.setFontWeight(QFont.Weight.Bold)
cls.boldOnSFormat.setFontPointSize(SMALL)
cls.boldOnSFormat.setBackground(cls.subduedBackground)
cls.italicFormat.setFontItalic(True)
cls.italicFormat.setFontPointSize(SMALL)
cls.textFormat.setFontPointSize(SMALL)
cls.smallCapsFormat.setFontPointSize(SMALL)
cls.smallCapsFormat.setFontCapitalization(QFont.Capitalization.SmallCaps)
#
# Fonts # Fonts
# #
cls.headerFont = QFontDatabase.font("OpenDyslexic", None, 10) cls.headerFont = QFontDatabase.font("OpenDyslexic", None, 10)
@@ -59,21 +109,12 @@ class Resources:
cls.headerFont.setWeight(QFont.Weight.Bold) cls.headerFont.setWeight(QFont.Weight.Bold)
cls.boldFont.setBold(True) cls.boldFont.setBold(True)
cls.italicFont.setItalic(True) cls.italicFont.setItalic(True)
print(f"Resources().italicFont: {cls.italicFont.toString()}")
print(f"Resources().boldFont: {cls.boldFont.toString()}")
cls.capsFont.setCapitalization(QFont.Capitalization.AllUppercase) cls.capsFont.setCapitalization(QFont.Capitalization.AllUppercase)
cls.smallCapsFont.setCapitalization(QFont.Capitalization.SmallCaps) cls.smallCapsFont.setCapitalization(QFont.Capitalization.SmallCaps)
cls.phonicFont = QFontDatabase.font("Gentium", None, 10) cls.phonicFont = QFontDatabase.font("Gentium", None, 10)
cls.phonicFont.setPixelSize(20) cls.phonicFont.setPixelSize(20)
#
# colors
#
cls.baseColor = QColor(Qt.GlobalColor.white)
cls.linkColor = QColor("#4a7d95")
cls.subduedColor = QColor(Qt.GlobalColor.gray)
# #
# Setup the Network Manager # Setup the Network Manager
# #

View File

@@ -1,39 +1,46 @@
import importlib import importlib
import pkgutil
import json import json
from typing import Any, TypedDict, cast import pkgutil
from types import ModuleType
from typing import Any, Iterable, TypedDict, cast
from PyQt6.QtCore import ( from PyQt6.QtCore import Qt, pyqtSlot
Qt,
pyqtSlot,
)
from PyQt6.QtSql import QSqlQuery from PyQt6.QtSql import QSqlQuery
from PyQt6.QtWidgets import QScrollArea from PyQt6.QtWidgets import QScrollArea
from trycast import trycast
from lib.utils import query_error
from lib.sounds import SoundOff
from lib.definition import Definition, Line
import plugins import plugins
def find_plugins(ns_pkg): from lib.definition import Definition, Line
return pkgutil.iter_modules(ns_pkg.__path__, ns_pkg.__name__ + '.') from lib.sounds import SoundOff
from lib.utils import query_error
def find_plugins(ns_pkg: ModuleType) -> Iterable[pkgutil.ModuleInfo]:
return pkgutil.iter_modules(ns_pkg.__path__, ns_pkg.__name__ + ".")
discovered_plugins = { discovered_plugins = {
# finder, name, ispkg # finder, name, ispkg
importlib.import_module(name).registration['source']: importlib.import_module(name) for _, name, _ in find_plugins(plugins) importlib.import_module(name).registration[
"source"
]: importlib.import_module(name)
for _, name, _ in find_plugins(plugins)
} }
API = "https://api.dictionaryapi.dev/api/v2/entries/en/{word}" API = "https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
class WordType(TypedDict): class WordType(TypedDict):
word: str word: str
source: str source: str
definition: str definition: str
class Word: class Word:
"""All processing of a dictionary word.""" """All processing of a dictionary word."""
_words: dict[str, WordType] = {} _words: dict[str, WordType] = {}
_valid = False
def __init__(self, word: str) -> None: def __init__(self, word: str) -> None:
# #
@@ -56,6 +63,7 @@ class Word:
"definition": json.loads(query.value("definition")), "definition": json.loads(query.value("definition")),
} }
self.current = Word._words[word] self.current = Word._words[word]
self._valid = True
return return
# #
# The code should look at our settings to see if we have an API # The code should look at our settings to see if we have an API
@@ -64,6 +72,9 @@ class Word:
source = "mw" source = "mw"
self._words[word] = discovered_plugins[source].fetch(word) self._words[word] = discovered_plugins[source].fetch(word)
if self._words[word] is None:
self._valid = False
return
self.current = Word._words[word] self.current = Word._words[word]
query.prepare( query.prepare(
"INSERT INTO words " "INSERT INTO words "
@@ -75,39 +86,41 @@ class Word:
query.bindValue(":definition", json.dumps(self.current["definition"])) query.bindValue(":definition", json.dumps(self.current["definition"]))
if not query.exec(): if not query.exec():
query_error(query) query_error(query)
self._valid = True
return return
def isValid(self) -> bool:
return self._valid
@pyqtSlot() @pyqtSlot()
def playSound(self) -> None: def playSound(self) -> None:
url = discovered_plugins[self.current['source']].getFirstSound(self.current['definition']) url = discovered_plugins[self.current["source"]].getFirstSound(
self.current["definition"]
)
if url.isValid(): if url.isValid():
snd = SoundOff() snd = SoundOff()
snd.playSound(url) snd.playSound(url)
return return
def playPRS(self) -> None:
return
def getWord(self) -> str: def getWord(self) -> str:
return cast(str, self.current["word"]) return self.current["word"]
def get_html(self) -> str | None: def get_html(self) -> str | None:
src = self.current['source'] src = self.current["source"]
try: try:
return discovered_plugins[src].getHtml(self.current) return cast(str, discovered_plugins[src].getHtml(self.current))
except KeyError: except KeyError:
raise Exception(f"Unknown source: {src}") raise Exception(f"Unknown source: {src}")
def get_def(self) -> list[Line]: def get_def(self) -> list[Line]:
src = self.current['source'] src = self.current["source"]
try: try:
lines = discovered_plugins[src].getDef(self.current["definition"]) lines = discovered_plugins[src].getDef(self.current["definition"])
lines = trycast(list[Line], lines)
assert lines is not None
return lines return lines
except KeyError: except KeyError:
raise Exception(f"Unknown source: {self.current['source']}") raise Exception(f"Unknown source: {self.current['source']}")
class DefinitionArea(QScrollArea):
def __init__(self, w: Word, *args: Any, **kwargs: Any) -> None:
super(DefinitionArea, self).__init__(*args, *kwargs)
d = Definition(w)
self.setWidget(d)
self.setWidgetResizable(True)
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
return

File diff suppressed because it is too large Load Diff

94
requirements.venv Normal file
View File

@@ -0,0 +1,94 @@
altgraph==0.17.4
asgiref==3.7.2
astor==0.8.1
astroid==3.0.3
asttokens==2.4.1
autopep8==2.0.4
beautifulsoup4==4.12.3
black==23.12.1
bs4==0.0.2
certifi==2023.11.17
charlockholmes==0.0.3
charset-normalizer==3.3.2
click==8.1.7
coloredlogs==15.0.1
css-inline==0.13.0
debugpy==1.8.1
decorator==5.1.1
dill==0.3.8
docstring-to-markdown==0.13
executing==2.0.1
flake8==7.0.0
flynt==1.0.1
future==0.18.3
futures==3.0.5
goslate==1.5.4
hiredis==2.3.2
humanfriendly==10.0
idna==3.6
importlib-metadata==7.0.1
ipython==8.20.0
isort==5.13.2
jedi==0.19.1
matplotlib-inline==0.1.6
mccabe==0.7.0
mime==0.1.0
mypy==1.8.0
mypy-extensions==1.0.0
packaging==23.2
param==2.1.0
parso==0.8.3
pathspec==0.12.1
pexpect==4.9.0
pickleshare==0.7.5
pillow==10.2.0
platformdirs==4.1.0
pluggy==1.4.0
prompt-toolkit==3.0.43
ptyprocess==0.7.0
pure-eval==0.2.2
pycodestyle==2.11.1
PyDictionary==2.0.1
pydocstyle==6.3.0
pyflakes==3.2.0
pygame==2.5.2
Pygments==2.17.2
pygments-github-lexers==0.0.5
pyinstaller==6.5.0
pyinstaller-hooks-contrib==2024.3
pylint==3.0.3
PyQt6==6.6.1
PyQt6-Qt6==6.6.1
PyQt6-sip==13.6.0
PySide6==6.6.1
PySide6-Addons==6.6.1
PySide6-Essentials==6.6.1
python-lsp-jsonrpc==1.1.2
python-lsp-server==1.10.0
pytoolconfig==1.3.1
PyYAML==6.0.1
qt6-applications==6.5.0.2.3
requests==2.31.0
rope==1.12.0
scanner==0.1.0
setuptools==69.0.3
shiboken6==6.6.1
six==1.16.0
snowballstemmer==2.2.0
soupsieve==2.5
sqlparse==0.4.4
stack-data==0.6.3
tabulate==0.9.0
tomli==2.0.1
tomlkit==0.12.3
traitlets==5.14.1
trycast==1.1.0
types-requests==2.31.0.20240106
typing_extensions==4.9.0
ujson==5.9.0
unicorn==2.0.1.post1
urllib3==2.1.0
wcwidth==0.2.13
whatthepatch==1.0.5
yapf==0.40.2
zipp==3.17.0