Browse Source

Clean up clusterview old code a little bit, first launch without the grouping stuff.

pull/1/head
Taylor Bockman 5 years ago
parent
commit
4c3be0b80e
  1. 8
      README.md
  2. 13
      TODO.org
  3. 28
      clusterview2.ui
  4. 10
      clusterview2/exceptions.py
  5. 5
      clusterview2/mode.py
  6. 364
      clusterview2/points.py
  7. 136
      clusterview2/ui/mode_handlers.py
  8. 23
      clusterview2/ui/opengl_widget.py
  9. 4
      clusterview2/ui/point_list_widget.py
  10. 26
      clusterview2_ui.py
  11. 143
      main_window.py
  12. 2
      setup.cfg

8
README.md

@ -12,3 +12,11 @@ TODO
Make sure to install the development requirements using `pip install -r requirements-dev.txt`. This will install Make sure to install the development requirements using `pip install -r requirements-dev.txt`. This will install
all main requirements as well as useful testing and linting tools. all main requirements as well as useful testing and linting tools.
### Regenerating the UI
After modifying the `*.ui` file in Qt Designer run
`pyuic5 clusterview2.ui -o clusterview2_ui.py`
to regenerate the UI python file.

13
TODO.org

@ -4,11 +4,14 @@ TODO:
the math library from kmeans (add a test to dist). Additionally, the point and cluster of the clusterview should the math library from kmeans (add a test to dist). Additionally, the point and cluster of the clusterview should
inherit from k-means point and cluster so that it can use the algorithm correctly. All other aspects of point and inherit from k-means point and cluster so that it can use the algorithm correctly. All other aspects of point and
cluster in the clusterview program can be kept. cluster in the clusterview program can be kept.
* Extract and improve the overall structure of clusterview so that globals are not used unless absolutely necessary. * See note in opengl_widget. Make it a static class.
* Do the same thing you did for opengl_widget in mode_handlers, leave the MODE_HANDLERS constant global.
* Port over all tests from clusterview except the math tests. Improve point tests to include weight.
* Turn kmeans into a python package and import it here. * Turn kmeans into a python package and import it here.
* Remove old clusterview buttons, keep the status window side and saving feature. Add new buttons for weighted and
unweighted clustering.
* Add a property to the point called weight, which is set when you click edit point. It will give a popup that * Add a property to the point called weight, which is set when you click edit point. It will give a popup that
allows you to specify a point weight. allows you to specify a point weight. DEFAULT WEIGHT IS 1
* Use kmeans to do the stuff. * Use kmeans to do the stuff - strip down point x y to refer to it's kmeans parent.
* Weighted cluster mean is rendered as a hollow circle and unweighted k-means mean is a x. * Weighted cluster mean is rendered as a hollow circle and unweighted k-means mean is a x.
* Saving should save the weights, load should load the weights.
* Make the current context exception string in opengl widget a constant and use that instead.
* Add typing to every function except setters and __XYZ__ functions (__init__ can still have typing)

28
clusterview2.ui

@ -29,7 +29,7 @@
</size> </size>
</property> </property>
<property name="windowTitle"> <property name="windowTitle">
<string>ClusterView</string> <string>ClusterView2</string>
</property> </property>
<widget class="QWidget" name="centralwidget"> <widget class="QWidget" name="centralwidget">
<layout class="QHBoxLayout" name="horizontalLayout"> <layout class="QHBoxLayout" name="horizontalLayout">
@ -139,23 +139,13 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="5" column="0">
<widget class="QPushButton" name="solve_button">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Solve</string>
</property>
</widget>
</item>
<item row="4" column="0"> <item row="4" column="0">
<widget class="QPushButton" name="group_button"> <widget class="QPushButton" name="unweighted_clustering_button">
<property name="enabled"> <property name="enabled">
<bool>false</bool> <bool>false</bool>
</property> </property>
<property name="text"> <property name="text">
<string>Group</string> <string>Unweighted Clustering</string>
</property> </property>
</widget> </widget>
</item> </item>
@ -166,6 +156,16 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="5" column="0">
<widget class="QPushButton" name="weighted_clustering_button">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Weighted Clustering</string>
</property>
</widget>
</item>
</layout> </layout>
</widget> </widget>
</item> </item>
@ -259,7 +259,7 @@
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>1280</width> <width>1280</width>
<height>28</height> <height>21</height>
</rect> </rect>
</property> </property>
<property name="nativeMenuBar"> <property name="nativeMenuBar">

10
clusterview2/exceptions.py

@ -2,12 +2,15 @@ from PyQt5.QtWidgets import QErrorMessage
from clusterview2.mode import Mode from clusterview2.mode import Mode
class ExceededWindowBoundsError(Exception): class ExceededWindowBoundsError(Exception):
pass pass
class InvalidStateError(Exception): class InvalidStateError(Exception):
pass pass
class InvalidModeError(Exception): class InvalidModeError(Exception):
""" """
An exception to specify an invalid mode has been provided. An exception to specify an invalid mode has been provided.
@ -20,12 +23,13 @@ class InvalidModeError(Exception):
""" """
if not isinstance(mode, Mode): if not isinstance(mode, Mode):
raise ValueError("Mode argument to InvalidMode must be of " + raise ValueError('Mode argument to InvalidMode must be of ' +
" type mode") ' type mode')
# Mode cases for invalid mode # Mode cases for invalid mode
if mode == Mode.OFF: if mode == Mode.OFF:
super().__init__("You must select a mode before continuing.") super().__init__('You must select a mode before continuing.')
def handle_exceptions(func): def handle_exceptions(func):
""" """

5
clusterview2/mode.py

@ -13,5 +13,6 @@ class Mode(Enum):
MOVE = 3 MOVE = 3
DELETE = 4 DELETE = 4
LOADED = 5 LOADED = 5
CHOOSE_CENTROIDS = 6 # TODO: Can replace with choose weighted or something CHOOSE_CENTROIDS = 6
GROUP = 7 UNWEIGHTED_CLUSTERING = 7
WEIGHTED_CLUSTERING = 8

364
clusterview2/points.py

@ -0,0 +1,364 @@
from math import floor
from .colors import Color
from .exceptions import ExceededWindowBoundsError
# TODO: THIS WILL NEED TO BE MODIFIED TO INHEIRIT THE KMEANS POINT CLASS.
class Point:
"""
A class representing a point. A point
has a point_size bounding box around
it.
"""
def __init__(self, x, y, color, point_size,
viewport_width, viewport_height):
"""
Initializes a new point with a point_size bounding box, viewport
awareness, and a color.
Initialized with additional viewport data to make sure the
move function refuses to move a point outside the screen.
@param x The x-coordinate.
@param y The y-coordinate.
@param color The color of the point.
@param point_size The size of the point in pixels.
@param viewport_width The width of the viewport.
@param viewport_height The height of the viewport.
"""
if not isinstance(color, Color):
raise ValueError("Point must be initialized with a color of " +
"type Color.")
self.__point_size = point_size
self.__x = x
self.__y = y
self.__color = color
self.__viewport_width = viewport_width
self.__viewport_height = viewport_height
self.__calculate_hitbox()
self.__check_window_bounds(x, y)
self.__selected = False
self.__attributes = []
@property
def x(self):
return self.__x
@property
def y(self):
return self.__y
@property
def point_size(self):
return self.__point_size
@property
def selected(self):
return self.__selected
@property
def color(self):
return self.__color
@color.setter
def color(self, color):
if not isinstance(color, Color):
raise ValueError('Point color must be of type Color.')
self.__color = color
@property
def attributes(self):
return self.__attributes
def add_attribute(self, attr):
self.__attributes.append(attr)
def __calculate_hitbox(self):
"""
Calculates the hit box for the point given the current
position (center) and the point size.
"""
half_point = floor(self.point_size / 2.0)
self.__top_left_corner = (self.__x - half_point,
self.__y + half_point)
self.__bottom_right_corner = (self.__x + half_point,
self.__y - half_point)
def __check_window_bounds(self, x, y):
"""
Simple window bound check that raises an exception when
the point (x, y) exceeds the known viewport bounds.
@param x The x-coordinate under test.
@param y The y-coordinate under test.
@raises ExceededWindowBoundsError If the viewport bounds are exceeded.
"""
half_point = floor(self.point_size / 2.0)
# Screen size in pixels is always positive
# We need to include the half point here because
# the (x, y) for a point is the center of the square and we
# do not want the EDGES to exceed the viewport bounds.
if (x > self.__viewport_width - half_point or
y > self.__viewport_height - half_point or
x < half_point or
y < half_point):
raise ExceededWindowBoundsError
def move(self, dx, dy):
"""
Adds the deltas dx and dy to the point.
@param dx The delta in the x direction.
@param dy The delta in the y direction.
"""
self.__check_window_bounds(self.__x + dx, self.__y + dy)
self.__x += dx
self.__y += dy
# It's important to note as we move the point we need to
# make sure we are constantly updating it's hitbox.
self.__calculate_hitbox()
def __eq__(self, other):
"""
Override for class equality.
@param other The other object.
"""
return (self.__x == other.x and
self.__y == other.y and
self.__color == other.color and
self.__attributes == other.attributes and
self.__point_size == other.point_size)
def __repr__(self):
# For some reason I had to split this instead of using one giant
# string chained with `+` inside of `()`.
s = "<POINT "
s += f"X: {self.__x} | Y: {self.__y} | "
s += f"SIZE: {self.__point_size} | "
s += f"COLOR: {self.__color} | "
s += f"VIEWPORT_WIDTH: {self.__viewport_width} | "
s += f"VIEWPORT_HEIGHT: {self.__viewport_height}"
s += ">"
return s
def select(self):
"""
Selects the point.
"""
self.__selected = True
def unselect(self):
"""
Unselects the point.
"""
self.__selected = False
def hit(self, x, y):
"""
Determines if the point was hit inside of it's bounding box.
The condition for hit is simple - consider the following
bounding box:
-------------
| |
| (x,y) |
| |
-------------
Where the clicked location is in the center. Then the top
left corner is defined as (x - half_point_size, y + half_point_size)
and the bottom corner is (x + half_point_size, y - half_point_size)
So long as x and y are greater than the top left and less than the
top right it is considered a hit.
This function is necessary for properly deleting and selecting points.
"""
return (x >= self.__top_left_corner[0] and
x <= self.__bottom_right_corner[0] and
y <= self.__top_left_corner[1] and
y >= self.__bottom_right_corner[1])
class Attribute:
def __init__(self, name, value):
"""
Initializes an attribute.
"""
self.__name = name
self.__value = value
class PointSet:
"""
Useful container for points. Since points are not hashable (they are
modified in place by move) we are forced to back the PointSet with an
array. However, it is still a "set" in the "uniqueness among all points"
sense because `add_point` will reject a point with a duplicate center.
"""
def __init__(self, point_size, viewport_width, viewport_height):
"""
Initializes a point container with points of size point_size.
@param point_size The size of the points.
@param viewport_width The width of the viewport for bounds
calculations.
@param viewport_height The height of the viewport for bounds
calculations.
"""
self.__points = []
self.__point_size = point_size
self.__viewport_width = viewport_width
self.__viewport_height = viewport_height
def __eq__(self, other):
other_points = list(other.points)
return (self.__points == other_points and
self.__point_size == other.point_size and
self.__viewport_width == other.viewport_width and
self.__viewport_height == other.viewport_height)
def __repr__(self):
s = []
for p in self.__points:
s.append(str(p))
return ",".join(s)
def clear(self):
self.__points = []
@property
def points(self):
"""
Getter for points. Returns a generator for
looping.
"""
for point in self.__points:
yield point
@property
def point_size(self):
return self.__point_size
@property
def viewport_height(self):
return self.__viewport_height
@property
def viewport_width(self):
return self.__viewport_width
@viewport_height.setter
def viewport_height(self, height):
self.__viewport_height = height
@viewport_width.setter
def viewport_width(self, width):
self.__viewport_width = width
def empty(self):
return len(self.__points) == 0
def clear_selection(self):
"""
Handy helper function to clear all selected points.
"""
for p in self.__points:
p.unselect()
def add_point(self, x, y, color, attrs=[]):
"""
Adds a point in screen coordinates and an optional attribute to
the list.
@param x The x-coordinate.
@param y The y-coordinate.
@param color The color of the point.
@param attr An optional attribute.
@raises ExceededWindowBoundsError If the point could not be constructed
because it would be outside the
window bounds.
"""
if attrs != [] and not all(isinstance(x, Attribute) for x in attrs):
raise ValueError("Attributes in add_point must be an " +
"attribute array.")
if not isinstance(color, Color):
raise ValueError("Point color must be a Color enum.")
point = Point(x, y, color, self.__point_size,
self.__viewport_width, self.__viewport_height)
for attr in attrs:
point.add_attribute(attr)
if point in self.__points:
# Silently reject a duplicate point (same center).
return
self.__points.append(point)
def remove_point(self, x, y):
"""
Removes a point from the point set based on a bounding
box calculation.
Removing a point is an exercise is determining which points
have been hit, and then pulling them out of the list.
If two points have a section overlapping, and the user clicks
the overlapped section, both points will be removed.
Currently O(n).
@param x The x-coordinate.
@param y The y-coordinate.
"""
for p in self.__points:
if p.hit(x, y):
self.__points.remove(p)
def groups(self):
"""
Returns a map from color to point representing each point's group
membership based on color.
"""
g = {}
for p in self.__points:
if p.color not in g:
# Create the key for the group color since it does
# not exist.
g[p.color] = []
g[p.color].append(p)
return g

136
clusterview2/ui/mode_handlers.py

@ -12,7 +12,7 @@ from .opengl_widget import (set_drawing_event, set_move_bb_top_left,
from clusterview2.point_manager import PointManager from clusterview2.point_manager import PointManager
class __ClickFlag: class _ClickFlag:
# This is the first stage. On mouse release it goes to # This is the first stage. On mouse release it goes to
# SELECTION_MOVE. # SELECTION_MOVE.
@ -35,23 +35,23 @@ class __ClickFlag:
# GLOBALS # GLOBALS
# Canvas pixel border - empirical, not sure where this is stored officially # Canvas pixel border - empirical, not sure where this is stored officially
__CANVAS_BORDER = 1 _CANVAS_BORDER = 1
# Module level flag for left click events (used to detect a left # Module level flag for left click events (used to detect a left
# click hold drag) # click hold drag)
__left_click_flag = __ClickFlag.NONE _left_click_flag = _ClickFlag.NONE
# Variable to track the mouse state during selection movement # Variable to track the mouse state during selection movement
__last_mouse_pos = None _last_mouse_pos = None
# Used to implement mouse dragging when clicked # Used to implement mouse dragging when clicked
__left_click_down = False _left_click_down = False
# TODO: WHEN THE GROUPING ENDS AND THE USER CANCELS THE CENTROID COUNT # TODO: WHEN THE GROUPING ENDS AND THE USER CANCELS THE CENTROID COUNT
# SHOULD BE ZEROED AND REMAINING COLORS SHOULD BE REPOPULATED. # SHOULD BE ZEROED AND REMAINING COLORS SHOULD BE REPOPULATED.
# Count of centroids for comparison with the spin widget # Count of centroids for comparison with the spin widget
__centroid_count = 0 _centroid_count = 0
__remaining_colors = [c for c in Color if c not in [Color.BLUE, Color.GREY]] _remaining_colors = [c for c in Color if c not in [Color.BLUE, Color.GREY]]
def refresh_point_list(ctx): def refresh_point_list(ctx):
@ -62,7 +62,7 @@ def refresh_point_list(ctx):
""" """
# In order to make some guarantees and avoid duplicate # In order to make some guarantees and avoid duplicate
# data we will clear the point list widget and re-populate # data we will clear the point list widget and re-populate
# it using the current __point_set. # it using the current _point_set.
ctx.point_list_widget.clear() ctx.point_list_widget.clear()
for p in PointManager.point_set.points: for p in PointManager.point_set.points:
@ -71,7 +71,7 @@ def refresh_point_list(ctx):
ctx.point_list_widget.update() ctx.point_list_widget.update()
def __handle_add_point(ctx, event): def _handle_add_point(ctx, event):
""" """
Event handler for the add point mode. Event handler for the add point mode.
@ -84,7 +84,7 @@ def __handle_add_point(ctx, event):
""" """
# Update information as needed # Update information as needed
__handle_info_updates(ctx, event) _handle_info_updates(ctx, event)
if (event.button() == Qt.LeftButton and if (event.button() == Qt.LeftButton and
event.type() == QEvent.MouseButtonPress): event.type() == QEvent.MouseButtonPress):
@ -114,7 +114,7 @@ def __handle_add_point(ctx, event):
ctx.point_list_widget.update() ctx.point_list_widget.update()
def __handle_edit_point(ctx, event): def _handle_edit_point(ctx, event):
# TODO: This function and delete definitely need to make sure they are # TODO: This function and delete definitely need to make sure they are
# on a point we have. # on a point we have.
# #
@ -127,7 +127,7 @@ def __handle_edit_point(ctx, event):
# Should move the associated point in the list to the new location if # Should move the associated point in the list to the new location if
# applicable. # applicable.
__handle_info_updates(ctx, event) _handle_info_updates(ctx, event)
PointManager.point_set.clear_selection() PointManager.point_set.clear_selection()
# Store old x, y from event # Store old x, y from event
@ -147,16 +147,16 @@ def ogl_keypress_handler(ctx, event):
@param ctx A handle to the window context. @param ctx A handle to the window context.
@param event The event associated with this handler. @param event The event associated with this handler.
""" """
global __left_click_flag global _left_click_flag
global __last_mouse_pos global _last_mouse_pos
if event.key() == Qt.Key_Escape: if event.key() == Qt.Key_Escape:
if ctx.mode is Mode.MOVE: if ctx.mode is Mode.MOVE:
if __left_click_flag is not __ClickFlag.NONE: if _left_click_flag is not _ClickFlag.NONE:
__last_mouse_pos = None _last_mouse_pos = None
__left_click_flag = __ClickFlag.NONE _left_click_flag = _ClickFlag.NONE
PointManager.point_set.clear_selection() PointManager.point_set.clear_selection()
reset_move_bbs() reset_move_bbs()
refresh_point_list(ctx) refresh_point_list(ctx)
@ -171,7 +171,7 @@ def ogl_keypress_handler(ctx, event):
ctx.opengl_widget.update() ctx.opengl_widget.update()
def __handle_move_points(ctx, event): def _handle_move_points(ctx, event):
""" """
A relatively complicated state machine that handles the process of A relatively complicated state machine that handles the process of
selection, clicking, and dragging. selection, clicking, and dragging.
@ -180,52 +180,52 @@ def __handle_move_points(ctx, event):
@param event The event. @param event The event.
""" """
global __left_click_flag global _left_click_flag
global __left_mouse_down global _left_mouse_down
global __last_mouse_pos global _last_mouse_pos
set_drawing_event(event) set_drawing_event(event)
__handle_info_updates(ctx, event) _handle_info_updates(ctx, event)
# If we release the mouse, we want to quickly alert drag mode. # If we release the mouse, we want to quickly alert drag mode.
if (event.button() == Qt.LeftButton and if (event.button() == Qt.LeftButton and
event.type() == QEvent.MouseButtonRelease): event.type() == QEvent.MouseButtonRelease):
__left_mouse_down = False _left_mouse_down = False
# This if statement block is used to set the bounding box for # This if statement block is used to set the bounding box for
# drawing and call the selection procedure. # drawing and call the selection procedure.
if (event.button() == Qt.LeftButton and if (event.button() == Qt.LeftButton and
event.type() == QEvent.MouseButtonPress): event.type() == QEvent.MouseButtonPress):
__left_mouse_down = True _left_mouse_down = True
if __left_click_flag is __ClickFlag.NONE: if _left_click_flag is _ClickFlag.NONE:
__left_click_flag = __ClickFlag.SELECTION_BOX _left_click_flag = _ClickFlag.SELECTION_BOX
set_move_bb_top_left(event.x(), event.y()) set_move_bb_top_left(event.x(), event.y())
elif (__left_click_flag is __ClickFlag.SELECTION_BOX elif (_left_click_flag is _ClickFlag.SELECTION_BOX
and __left_mouse_down): and _left_mouse_down):
# We are now in the click-and-hold to signal move # We are now in the click-and-hold to signal move
# tracking and translation # tracking and translation
__left_click_flag = __ClickFlag.SELECTION_MOVE _left_click_flag = _ClickFlag.SELECTION_MOVE
__last_mouse_pos = (event.x(), event.y()) _last_mouse_pos = (event.x(), event.y())
# Post-selection handlers # Post-selection handlers
if (__left_click_flag is __ClickFlag.SELECTION_BOX if (_left_click_flag is _ClickFlag.SELECTION_BOX
and event.type() == QEvent.MouseMove): and event.type() == QEvent.MouseMove):
set_move_bb_bottom_right(event.x(), event.y()) set_move_bb_bottom_right(event.x(), event.y())
elif (__left_click_flag is __ClickFlag.SELECTION_MOVE elif (_left_click_flag is _ClickFlag.SELECTION_MOVE
and __last_mouse_pos is not None and _last_mouse_pos is not None
and __left_mouse_down and _left_mouse_down
and event.type() == QEvent.MouseMove): and event.type() == QEvent.MouseMove):
dx = abs(__last_mouse_pos[0] - event.x()) dx = abs(_last_mouse_pos[0] - event.x())
dy = abs(__last_mouse_pos[1] - event.y()) dy = abs(_last_mouse_pos[1] - event.y())
for p in PointManager.point_set.points: for p in PointManager.point_set.points:
if p.selected: if p.selected:
@ -235,13 +235,13 @@ def __handle_move_points(ctx, event):
# fly off screen quickly as we got farther from our # fly off screen quickly as we got farther from our
# start. # start.
try: try:
if event.x() < __last_mouse_pos[0]: if event.x() < _last_mouse_pos[0]:
p.move(-dx, 0) p.move(-dx, 0)
if event.y() < __last_mouse_pos[1]: if event.y() < _last_mouse_pos[1]:
p.move(0, -dy) p.move(0, -dy)
if event.x() > __last_mouse_pos[0]: if event.x() > _last_mouse_pos[0]:
p.move(dx, 0) p.move(dx, 0)
if event.y() > __last_mouse_pos[1]: if event.y() > _last_mouse_pos[1]:
p.move(0, dy) p.move(0, dy)
except ExceededWindowBoundsError: except ExceededWindowBoundsError:
@ -250,12 +250,12 @@ def __handle_move_points(ctx, event):
# point. # point.
continue continue
__last_mouse_pos = (event.x(), event.y()) _last_mouse_pos = (event.x(), event.y())
elif (__left_click_flag is not __ClickFlag.NONE and elif (_left_click_flag is not _ClickFlag.NONE and
event.type() == QEvent.MouseButtonRelease): event.type() == QEvent.MouseButtonRelease):
if __left_click_flag is __ClickFlag.SELECTION_BOX: if _left_click_flag is _ClickFlag.SELECTION_BOX:
set_move_bb_bottom_right(event.x(), event.y()) set_move_bb_bottom_right(event.x(), event.y())
@ -265,9 +265,9 @@ def __handle_move_points(ctx, event):
ctx.opengl_widget.update() ctx.opengl_widget.update()
def __handle_delete_point(ctx, event): def _handle_delete_point(ctx, event):
__handle_info_updates(ctx, event) _handle_info_updates(ctx, event)
if (event.button() == Qt.LeftButton and if (event.button() == Qt.LeftButton and
event.type() == QEvent.MouseButtonPress): event.type() == QEvent.MouseButtonPress):
@ -282,7 +282,7 @@ def __handle_delete_point(ctx, event):
ctx.point_list_widget.update() ctx.point_list_widget.update()
def __handle_info_updates(ctx, event): def _handle_info_updates(ctx, event):
""" """
Updates data under the "information" header. Updates data under the "information" header.
@ -293,19 +293,19 @@ def __handle_info_updates(ctx, event):
ctx.mouse_position_label.setText(f"{event.x(), event.y()}") ctx.mouse_position_label.setText(f"{event.x(), event.y()}")
def __handle_choose_centroids(ctx, event): def _handle_choose_centroids(ctx, event):
""" """
Similar to move in terms of selecting points, however this Similar to move in terms of selecting points, however this
function assigns a random color up to the maximum number function assigns a random color up to the maximum number
of centroids, and after the maximum number has been selected it will of centroids, and after the maximum number has been selected it will
enable the group button. enable the group button.
""" """
global __centroid_count global _centroid_count
global __remaining_colors global _remaining_colors
__handle_info_updates(ctx, event) _handle_info_updates(ctx, event)
if __centroid_count == ctx.number_of_centroids.value(): if _centroid_count == ctx.number_of_centroids.value():
# We have specified the number of centroids required # We have specified the number of centroids required
return return
@ -326,32 +326,32 @@ def __handle_choose_centroids(ctx, event):
# Centroids must be unique # Centroids must be unique
return return
__centroid_count += 1 _centroid_count += 1
color = random.choice(__remaining_colors) color = random.choice(_remaining_colors)
__remaining_colors.remove(color) _remaining_colors.remove(color)
point.color = color point.color = color
# Recolor the point and restash the point in centroids # Recolor the point and restash the point in centroids
PointManager.centroids.append(point) PointManager.centroids.append(point)
if __centroid_count == ctx.number_of_centroids.value(): if _centroid_count == ctx.number_of_centroids.value():
# Prevent the user from changing the centroids # Prevent the user from changing the centroids
ctx.number_of_centroids.setEnabled(False) ctx.number_of_centroids.setEnabled(False)
ctx.choose_centroids_button.setEnabled(False) ctx.choose_centroids_button.setEnabled(False)
ctx.group_button.setEnabled(True) ctx.unweighted_clustering_button.setEnabled(True)
ctx.weighted_clustering_button.setEnabled(True)
ctx.opengl_widget.update() ctx.opengl_widget.update()
def reset_centroid_count_and_colors(): def reset_centroid_count_and_colors():
global __centroid_count global _centroid_count
global __remaining_colors global _remaining_colors
__centroid_count = 0 _centroid_count = 0
__remaining_colors = [c for c in Color if _remaining_colors = [c for c in Color if c not in [Color.BLUE, Color.GREY]]
c not in [Color.BLUE, Color.GREY]]
for point in PointManager.point_set.points: for point in PointManager.point_set.points:
point.color = Color.GREY point.color = Color.GREY
@ -392,11 +392,11 @@ def generate_random_points(point_count, x_bound, y_bound):
# Simple dispatcher to make it easy to dispatch the right mode # Simple dispatcher to make it easy to dispatch the right mode
# function when the OpenGL window is acted on. # function when the OpenGL window is acted on.
MODE_HANDLER_MAP = { MODE_HANDLER_MAP = {
Mode.OFF: __handle_info_updates, Mode.OFF: _handle_info_updates,
Mode.LOADED: __handle_info_updates, Mode.LOADED: _handle_info_updates,
Mode.ADD: __handle_add_point, Mode.ADD: _handle_add_point,
Mode.EDIT: __handle_edit_point, Mode.EDIT: _handle_edit_point,
Mode.MOVE: __handle_move_points, Mode.MOVE: _handle_move_points,
Mode.DELETE: __handle_delete_point, Mode.DELETE: _handle_delete_point,
Mode.CHOOSE_CENTROIDS: __handle_choose_centroids Mode.CHOOSE_CENTROIDS: _handle_choose_centroids
} }

23
clusterview2/ui/opengl_widget.py

@ -43,6 +43,10 @@ __current_context = None
__current_event = None __current_event = None
# TODO: This should live inside of a class as static methods with the
# globals moved into the static scope to make this nicer...once you
# get it running before doing kmeans make this modification.
def set_drawing_context(ctx): def set_drawing_context(ctx):
""" """
Sets the drawing context so that drawing functions can properly Sets the drawing context so that drawing functions can properly
@ -63,8 +67,8 @@ def set_drawing_event(event):
global __current_event global __current_event
if __current_context is None: if __current_context is None:
raise InvalidStateError("Drawing context must be set before setting " + raise InvalidStateError('Drawing context must be set before setting ' +
"drawing mode") 'drawing mode')
if event is not None: if event is not None:
__current_event = event __current_event = event
@ -180,12 +184,13 @@ def paint_gl():
__current_context.mode is Mode.DELETE or __current_context.mode is Mode.DELETE or
__current_context.mode is Mode.LOADED or __current_context.mode is Mode.LOADED or
__current_context.mode is Mode.CHOOSE_CENTROIDS or __current_context.mode is Mode.CHOOSE_CENTROIDS or
__current_context.mode is Mode.GROUP): __current_context.mode is Mode.UNWEIGHTED_CLUSTERING or
__current_context.mode is Mode.WEIGHTED_CLUSTERING):
draw_points(PointManager.point_set) draw_points(PointManager.point_set)
elif __current_context.mode is Mode.EDIT: elif __current_context.mode is Mode.EDIT:
raise NotImplementedError("Drawing for EDIT not implemented.") raise NotImplementedError('Drawing for EDIT not implemented.')
elif __current_context.mode is Mode.MOVE: elif __current_context.mode is Mode.MOVE:
# We have to repeatedly draw the points while we are showing the # We have to repeatedly draw the points while we are showing the
@ -298,11 +303,11 @@ def draw_selection_box(color):
global __current_context global __current_context
if __current_context is None: if __current_context is None:
raise InvalidStateError("Drawing context must be set before setting " + raise InvalidStateError('Drawing context must be set before setting ' +
"drawing mode") 'drawing mode')
if not isinstance(color, Color): if not isinstance(color, Color):
raise ValueError("Color must exist in the Color enumeration") raise ValueError('Color must exist in the Color enumeration')
if __move_bb_top_left is None or __move_bb_bottom_right is None: if __move_bb_top_left is None or __move_bb_bottom_right is None:
# Nothing to draw. # Nothing to draw.
@ -367,8 +372,8 @@ def draw_points(point_set):
global __current_context global __current_context
if __current_context is None: if __current_context is None:
raise InvalidStateError("Drawing context must be set before setting " + raise InvalidStateError('Drawing context must be set before setting ' +
"drawing mode") 'drawing mode')
glViewport(0, 0, __WIDTH, __HEIGHT) glViewport(0, 0, __WIDTH, __HEIGHT)
glPointSize(PointManager.point_set.point_size) glPointSize(PointManager.point_set.point_size)

4
clusterview2/ui/point_list_widget.py

@ -8,7 +8,7 @@ is defined in the clusterview_ui.py file.
from clusterview2.point_manager import PointManager from clusterview2.point_manager import PointManager
def __string_point_to_point(str_point): def _string_point_to_point(str_point):
""" """
In the QListWidget points are stored as strings In the QListWidget points are stored as strings
because of the way Qt has list items defined. because of the way Qt has list items defined.
@ -39,7 +39,7 @@ def item_click_handler(ctx, item):
@param ctx The context. @param ctx The context.
@param item The clicked item. @param item The clicked item.
""" """
point = __string_point_to_point(item.text()) point = _string_point_to_point(item.text())
# TODO: Super slow linear search, should write a find_point function # TODO: Super slow linear search, should write a find_point function
# on the point_set in order to speed this up since PointSet # on the point_set in order to speed this up since PointSet

26
clusterview2_ui.py

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Form implementation generated from reading ui file 'clusterview.ui' # Form implementation generated from reading ui file 'clusterview2.ui'
# #
# Created by: PyQt5 UI code generator 5.13.0 # Created by: PyQt5 UI code generator 5.13.0
# #
@ -78,17 +78,17 @@ class Ui_MainWindow(object):
self.choose_centroids_button.setEnabled(True) self.choose_centroids_button.setEnabled(True)
self.choose_centroids_button.setObjectName("choose_centroids_button") self.choose_centroids_button.setObjectName("choose_centroids_button")
self.formLayout.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.choose_centroids_button) self.formLayout.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.choose_centroids_button)
self.solve_button = QtWidgets.QPushButton(self.groupBox_3) self.unweighted_clustering_button = QtWidgets.QPushButton(self.groupBox_3)
self.solve_button.setEnabled(False) self.unweighted_clustering_button.setEnabled(False)
self.solve_button.setObjectName("solve_button") self.unweighted_clustering_button.setObjectName("unweighted_clustering_button")
self.formLayout.setWidget(5, QtWidgets.QFormLayout.LabelRole, self.solve_button) self.formLayout.setWidget(4, QtWidgets.QFormLayout.LabelRole, self.unweighted_clustering_button)
self.group_button = QtWidgets.QPushButton(self.groupBox_3)
self.group_button.setEnabled(False)
self.group_button.setObjectName("group_button")
self.formLayout.setWidget(4, QtWidgets.QFormLayout.LabelRole, self.group_button)
self.reset_button = QtWidgets.QPushButton(self.groupBox_3) self.reset_button = QtWidgets.QPushButton(self.groupBox_3)
self.reset_button.setObjectName("reset_button") self.reset_button.setObjectName("reset_button")
self.formLayout.setWidget(6, QtWidgets.QFormLayout.LabelRole, self.reset_button) self.formLayout.setWidget(6, QtWidgets.QFormLayout.LabelRole, self.reset_button)
self.weighted_clustering_button = QtWidgets.QPushButton(self.groupBox_3)
self.weighted_clustering_button.setEnabled(False)
self.weighted_clustering_button.setObjectName("weighted_clustering_button")
self.formLayout.setWidget(5, QtWidgets.QFormLayout.LabelRole, self.weighted_clustering_button)
self.verticalLayout.addWidget(self.groupBox_3) self.verticalLayout.addWidget(self.groupBox_3)
spacerItem = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) spacerItem = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed)
self.verticalLayout.addItem(spacerItem) self.verticalLayout.addItem(spacerItem)
@ -117,7 +117,7 @@ class Ui_MainWindow(object):
self.horizontalLayout.addLayout(self.verticalLayout) self.horizontalLayout.addLayout(self.verticalLayout)
MainWindow.setCentralWidget(self.centralwidget) MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QtWidgets.QMenuBar(MainWindow) self.menubar = QtWidgets.QMenuBar(MainWindow)
self.menubar.setGeometry(QtCore.QRect(0, 0, 1280, 28)) self.menubar.setGeometry(QtCore.QRect(0, 0, 1280, 21))
self.menubar.setNativeMenuBar(True) self.menubar.setNativeMenuBar(True)
self.menubar.setObjectName("menubar") self.menubar.setObjectName("menubar")
self.menu_file = QtWidgets.QMenu(self.menubar) self.menu_file = QtWidgets.QMenu(self.menubar)
@ -167,14 +167,14 @@ class Ui_MainWindow(object):
def retranslateUi(self, MainWindow): def retranslateUi(self, MainWindow):
_translate = QtCore.QCoreApplication.translate _translate = QtCore.QCoreApplication.translate
MainWindow.setWindowTitle(_translate("MainWindow", "ClusterView")) MainWindow.setWindowTitle(_translate("MainWindow", "ClusterView2"))
self.groupBox.setTitle(_translate("MainWindow", "Point List")) self.groupBox.setTitle(_translate("MainWindow", "Point List"))
self.groupBox_3.setTitle(_translate("MainWindow", "Solver")) self.groupBox_3.setTitle(_translate("MainWindow", "Solver"))
self.label_2.setText(_translate("MainWindow", "Centroids")) self.label_2.setText(_translate("MainWindow", "Centroids"))
self.choose_centroids_button.setText(_translate("MainWindow", "Choose Centroids")) self.choose_centroids_button.setText(_translate("MainWindow", "Choose Centroids"))
self.solve_button.setText(_translate("MainWindow", "Solve")) self.unweighted_clustering_button.setText(_translate("MainWindow", "Unweighted Clustering"))
self.group_button.setText(_translate("MainWindow", "Group"))
self.reset_button.setText(_translate("MainWindow", "Reset")) self.reset_button.setText(_translate("MainWindow", "Reset"))
self.weighted_clustering_button.setText(_translate("MainWindow", "Weighted Clustering"))
self.groupBox_2.setTitle(_translate("MainWindow", "Canvas Information")) self.groupBox_2.setTitle(_translate("MainWindow", "Canvas Information"))
self.label.setText(_translate("MainWindow", "Mouse Position:")) self.label.setText(_translate("MainWindow", "Mouse Position:"))
self.menu_file.setTitle(_translate("MainWindow", "File")) self.menu_file.setTitle(_translate("MainWindow", "File"))

143
main_window.py

@ -15,6 +15,7 @@ from clusterview2.ui.mode_handlers import (MODE_HANDLER_MAP,
from clusterview2.ui.opengl_widget import (clear_selection, initialize_gl, from clusterview2.ui.opengl_widget import (clear_selection, initialize_gl,
mouse_leave, paint_gl, resize_gl, mouse_leave, paint_gl, resize_gl,
set_drawing_context) set_drawing_context)
from clusterview2.points import PointSet
from clusterview2.point_manager import PointManager from clusterview2.point_manager import PointManager
from clusterview2.ui.point_list_widget import item_click_handler from clusterview2.ui.point_list_widget import item_click_handler
from clusterview2_ui import Ui_MainWindow from clusterview2_ui import Ui_MainWindow
@ -29,25 +30,25 @@ class MainWindow(QMainWindow, Ui_MainWindow):
# This is a static mode variable since there will only ever # This is a static mode variable since there will only ever
# be one MainWindow. # be one MainWindow.
__mode = Mode.OFF _mode = Mode.OFF
def __init__(self, parent=None): def __init__(self, parent=None):
super(MainWindow, self).__init__(parent) super(MainWindow, self).__init__(parent)
self.setupUi(self) self.setupUi(self)
# Size of point for drawing # Size of point for drawing
self.__point_size = 8 self._point_size = 8
# TODO: THESE ARE HARD CODED TO THE CURRENT QT WIDGET SIZES # TODO: THESE ARE HARD CODED TO THE CURRENT QT WIDGET SIZES
# FIX THIS PROPERLY WITH A RESIZE EVENT DETECT. # FIX THIS PROPERLY WITH A RESIZE EVENT DETECT.
# PointManager is a class that is filled with static methods # PointManager is a class that is filled with static methods
# designed for managing state. # designed for managing state.
self.__viewport_width = 833 self._viewport_width = 833
self.__viewport_height = 656 self._viewport_height = 656
PointManager.point_set = PointSet(self.__point_size, PointManager.point_set = PointSet(self._point_size,
self.__viewport_width, self._viewport_width,
self.__viewport_height) self._viewport_height)
# Spin box should only allow the number of centroids to be no # Spin box should only allow the number of centroids to be no
# greater than the number of supported colors minus 2 to exclude # greater than the number of supported colors minus 2 to exclude
@ -82,11 +83,11 @@ class MainWindow(QMainWindow, Ui_MainWindow):
self.point_list_widget.itemClicked.connect(partial(item_click_handler, self.point_list_widget.itemClicked.connect(partial(item_click_handler,
self)) self))
self.choose_centroids_button.clicked.connect(self.__choose_centroids) self.choose_centroids_button.clicked.connect(self._choose_centroids)
self.group_button.clicked.connect(self.__group) self.unweighted_clustering_button.clicked.connect(self._unweighted_clustering)
self.reset_button.clicked.connect(self.__reset_grouping) self.reset_button.clicked.connect(self._reset)
# ----------------------------------------------- # -----------------------------------------------
# OpenGL Graphics Handlers are set # OpenGL Graphics Handlers are set
@ -100,108 +101,108 @@ class MainWindow(QMainWindow, Ui_MainWindow):
# ------------------------------------- # -------------------------------------
# UI Handlers # UI Handlers
# ------------------------------------- # -------------------------------------
self.action_add_points.triggered.connect(self.__add_points) self.action_add_points.triggered.connect(self._add_points)
self.action_edit_points.triggered.connect(self.__edit_points) self.action_edit_points.triggered.connect(self._edit_points)
self.action_delete_points.triggered.connect(self.__delete_points) self.action_delete_points.triggered.connect(self._delete_points)
self.action_move_points.triggered.connect(self.__move_points) self.action_move_points.triggered.connect(self._move_points)
(self.action_generate_random_points (self.action_generate_random_points
.triggered.connect(self.__generate_random_points)) .triggered.connect(self._generate_random_points))
self.action_save_point_configuration.triggered.connect( self.action_save_point_configuration.triggered.connect(
self.__save_points_file) self._save_points_file)
self.action_load_point_configuration.triggered.connect( self.action_load_point_configuration.triggered.connect(
self.__open_points_file) self._open_points_file)
self.action_exit.triggered.connect(self.__close_event) self.action_exit.triggered.connect(self._close_event)
# Override handler for mouse press so we can draw points based on # Override handler for mouse press so we can draw points based on
# the OpenGL coordinate system inside of the OpenGL Widget. # the OpenGL coordinate system inside of the OpenGL Widget.
self.opengl_widget.mousePressEvent = self.__ogl_click_dispatcher self.opengl_widget.mousePressEvent = self._ogl_click_dispatcher
self.opengl_widget.mouseMoveEvent = self.__ogl_click_dispatcher self.opengl_widget.mouseMoveEvent = self._ogl_click_dispatcher
self.opengl_widget.mouseReleaseEvent = self.__ogl_click_dispatcher self.opengl_widget.mouseReleaseEvent = self._ogl_click_dispatcher
# ----------------------------------------------------------------- # -----------------------------------------------------------------
# Mode changers - these will be used to signal the action in the # Mode changers - these will be used to signal the action in the
# OpenGL Widget. # OpenGL Widget.
# ----------------------------------------------------------------- # -----------------------------------------------------------------
def __off_mode(self): def _off_mode(self):
self.__mode = Mode.OFF self._mode = Mode.OFF
self.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) self.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
self.status_bar.showMessage("") self.status_bar.showMessage('')
clear_selection() clear_selection()
self.opengl_widget.update() self.opengl_widget.update()
def __add_points(self): def _add_points(self):
self.__mode = Mode.ADD self._mode = Mode.ADD
self.opengl_widget.setCursor(QCursor(Qt.CursorShape.CrossCursor)) self.opengl_widget.setCursor(QCursor(Qt.CursorShape.CrossCursor))
self.status_bar.showMessage("ADD MODE") self.status_bar.showMessage('ADD MODE')
clear_selection() clear_selection()
self.opengl_widget.update() self.opengl_widget.update()
def __edit_points(self): def _edit_points(self):
self.__mode = Mode.EDIT self._mode = Mode.EDIT
self.opengl_widget.setCursor(QCursor(Qt.CursorShape.CrossCursor)) self.opengl_widget.setCursor(QCursor(Qt.CursorShape.CrossCursor))
self.status_bar.showMessage("EDIT MODE") self.status_bar.showMessage('EDIT MODE')
clear_selection() clear_selection()
self.opengl_widget.update() self.opengl_widget.update()
def __delete_points(self): def _delete_points(self):
self.__mode = Mode.DELETE self._mode = Mode.DELETE
self.opengl_widget.setCursor(QCursor( self.opengl_widget.setCursor(QCursor(
Qt.CursorShape.PointingHandCursor)) Qt.CursorShape.PointingHandCursor))
self.status_bar.showMessage("DELETE MODE") self.status_bar.showMessage('DELETE MODE')
clear_selection() clear_selection()
self.opengl_widget.update() self.opengl_widget.update()
def __move_points(self): def _move_points(self):
self.__mode = Mode.MOVE self._mode = Mode.MOVE
self.opengl_widget.setCursor(QCursor(Qt.CursorShape.SizeAllCursor)) self.opengl_widget.setCursor(QCursor(Qt.CursorShape.SizeAllCursor))
self.status_bar.showMessage("MOVE MODE - PRESS ESC OR SWITCH MODES" + self.status_bar.showMessage('MOVE MODE - PRESS ESC OR SWITCH MODES ' +
"TO CANCEL SELECTION") 'TO CANCEL SELECTION')
clear_selection() clear_selection()
self.opengl_widget.update() self.opengl_widget.update()
def __choose_centroids(self): def _choose_centroids(self):
self.__mode = Mode.CHOOSE_CENTROIDS self._mode = Mode.CHOOSE_CENTROIDS
self.opengl_widget.setCursor(QCursor(Qt.CursorShape.CrossCursor)) self.opengl_widget.setCursor(QCursor(Qt.CursorShape.CrossCursor))
self.status_bar.showMessage("CHOOSE CENTROIDS") self.status_bar.showMessage('CHOOSE CENTROIDS')
clear_selection() clear_selection()
self.opengl_widget.update() self.opengl_widget.update()
def __group(self): def _unweighted_clustering(self):
self.__mode = Mode.GROUP self._mode = Mode.UNWEIGHTED_CLUSTERING
self.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) self.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
self.status_bar.showMessage("GROUPING") self.status_bar.showMessage('UNWEIGHTED CLUSTERING')
clear_selection() clear_selection()
group(self) # unweighted_clustering(self)
self.__off_mode() self._off_mode()
self.opengl_widget.update() self.opengl_widget.update()
def __reset_grouping(self): def _reset(self):
self.__off_mode() self._off_mode()
self.number_of_centroids.setEnabled(True) self.number_of_centroids.setEnabled(True)
self.number_of_centroids.setValue(0) self.number_of_centroids.setValue(0)
self.choose_centroids_button.setEnabled(True) self.choose_centroids_button.setEnabled(True)
self.solve_button.setEnabled(False) self.unweighted_clustering_button.setEnabled(False)
self.group_button.setEnabled(False) self.weighted_clustering_button.setEnabled(False)
PointManager.centroids = [] PointManager.centroids = []
reset_centroid_count_and_colors() reset_centroid_count_and_colors()
def __generate_random_points(self): def _generate_random_points(self):
value, ok = QInputDialog.getInt(self, "Number of Points", value, ok = QInputDialog.getInt(self, 'Number of Points',
"Number of Points:", 30, 30, 3000, 1) 'Number of Points:', 30, 30, 3000, 1)
if ok: if ok:
self.__mode = Mode.ADD self._mode = Mode.ADD
generate_random_points(value, generate_random_points(value,
(self.__viewport_width - self.__point_size), (self._viewport_width - self._point_size),
(self.__viewport_height - self.__point_size) (self._viewport_height - self._point_size)
) )
self.__mode = Mode.OFF self._mode = Mode.OFF
self.opengl_widget.update() self.opengl_widget.update()
@ -209,27 +210,27 @@ class MainWindow(QMainWindow, Ui_MainWindow):
@property @property
def mode(self): def mode(self):
""" """"
Function designed to be used from a context Function designed to be used from a context
to get the current mode. to get the current mode.
""" """
return self.__mode return self._mode
@mode.setter @mode.setter
def mode(self, mode): def mode(self, mode):
self.__mode = mode self._mode = mode
def __close_event(self, event): def _close_event(self, event):
import sys import sys
sys.exit(0) sys.exit(0)
def __open_points_file(self): def _open_points_file(self):
ofile, _ = QFileDialog.getOpenFileName(self, ofile, _ = QFileDialog.getOpenFileName(self,
"Open Point Configuration", 'Open Point Configuration',
"", '',
"JSON files (*.json)") 'JSON files (*.json)')
if ofile: if ofile:
self.__mode = Mode.LOADED self._mode = Mode.LOADED
PointManager.load(ofile) PointManager.load(ofile)
@ -237,17 +238,17 @@ class MainWindow(QMainWindow, Ui_MainWindow):
refresh_point_list(self) refresh_point_list(self)
def __save_points_file(self): def _save_points_file(self):
file_name, _ = (QFileDialog. file_name, _ = (QFileDialog.
getSaveFileName(self, getSaveFileName(self,
"Save Point Configuration", 'Save Point Configuration',
"", '',
"JSON Files (*.json)")) 'JSON Files (*.json)'))
if file_name: if file_name:
PointManager.save(file_name) PointManager.save(file_name)
@handle_exceptions @handle_exceptions
def __ogl_click_dispatcher(self, event): def _ogl_click_dispatcher(self, event):
""" """
Mode dispatcher for click actions on the OpenGL widget. Mode dispatcher for click actions on the OpenGL widget.
""" """
@ -256,4 +257,4 @@ class MainWindow(QMainWindow, Ui_MainWindow):
# OpenGL event. The context passed to these functions allows # OpenGL event. The context passed to these functions allows
# them to modify on screen widgets such as the QOpenGLWidget and # them to modify on screen widgets such as the QOpenGLWidget and
# QListWidget. # QListWidget.
MODE_HANDLER_MAP[self.__mode](self, event) MODE_HANDLER_MAP[self._mode](self, event)

2
setup.cfg

@ -0,0 +1,2 @@
[flake8]
max-line-length = 120
Loading…
Cancel
Save