Total Complexity | 111 |
Total Lines | 505 |
Duplicated Lines | 0 % |
Complex classes like neovim_gui.GtkUI often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
1 | """Neovim Gtk+ UI.""" |
||
67 | class GtkUI(object): |
||
68 | |||
69 | """Gtk+ UI class.""" |
||
70 | |||
71 | def __init__(self): |
||
72 | """Initialize the UI instance.""" |
||
73 | self._redraw_arg = None |
||
74 | self._foreground = -1 |
||
75 | self._background = -1 |
||
76 | self._font_size = 13 |
||
77 | self._font_name = 'Monospace' |
||
78 | self._screen = None |
||
79 | self._attrs = None |
||
80 | self._busy = False |
||
81 | self._mouse_enabled = False |
||
82 | self._insert_cursor = False |
||
83 | self._blink = False |
||
84 | self._blink_timer_id = None |
||
85 | self._resize_timer_id = None |
||
86 | self._pressed = None |
||
87 | self._invalid = None |
||
88 | self._pending = [0, 0, 0] |
||
89 | self._reset_cache() |
||
90 | |||
91 | def start(self, bridge): |
||
92 | """Start the UI event loop.""" |
||
93 | bridge.attach(80, 24, True) |
||
94 | drawing_area = Gtk.DrawingArea() |
||
95 | drawing_area.connect('draw', self._gtk_draw) |
||
96 | window = Gtk.Window() |
||
97 | window.add(drawing_area) |
||
98 | window.set_events(window.get_events() | |
||
99 | Gdk.EventMask.BUTTON_PRESS_MASK | |
||
100 | Gdk.EventMask.BUTTON_RELEASE_MASK | |
||
101 | Gdk.EventMask.POINTER_MOTION_MASK | |
||
102 | Gdk.EventMask.SCROLL_MASK) |
||
103 | window.connect('configure-event', self._gtk_configure) |
||
104 | window.connect('delete-event', self._gtk_quit) |
||
105 | window.connect('key-press-event', self._gtk_key) |
||
106 | window.connect('key-release-event', self._gtk_key_release) |
||
107 | window.connect('button-press-event', self._gtk_button_press) |
||
108 | window.connect('button-release-event', self._gtk_button_release) |
||
109 | window.connect('motion-notify-event', self._gtk_motion_notify) |
||
110 | window.connect('scroll-event', self._gtk_scroll) |
||
111 | window.connect('focus-in-event', self._gtk_focus_in) |
||
112 | window.connect('focus-out-event', self._gtk_focus_out) |
||
113 | window.show_all() |
||
114 | im_context = Gtk.IMMulticontext() |
||
115 | im_context.set_client_window(drawing_area.get_window()) |
||
116 | im_context.set_use_preedit(False) # TODO: preedit at cursor position |
||
117 | im_context.connect('commit', self._gtk_input) |
||
118 | self._pango_context = drawing_area.create_pango_context() |
||
119 | self._drawing_area = drawing_area |
||
120 | self._window = window |
||
121 | self._im_context = im_context |
||
122 | self._bridge = bridge |
||
123 | Gtk.main() |
||
124 | |||
125 | def quit(self): |
||
126 | """Exit the UI event loop.""" |
||
127 | GObject.idle_add(Gtk.main_quit) |
||
128 | |||
129 | def schedule_screen_update(self, apply_updates): |
||
130 | """Schedule screen updates to run in the UI event loop.""" |
||
131 | def wrapper(): |
||
132 | apply_updates() |
||
133 | self._flush() |
||
134 | self._start_blinking() |
||
135 | self._screen_invalid() |
||
136 | GObject.idle_add(wrapper) |
||
137 | |||
138 | def _screen_invalid(self): |
||
139 | self._drawing_area.queue_draw() |
||
140 | |||
141 | def _nvim_resize(self, columns, rows): |
||
142 | da = self._drawing_area |
||
143 | # create FontDescription object for the selected font/size |
||
144 | font_str = '{0} {1}'.format(self._font_name, self._font_size) |
||
145 | self._font, pixels, normal_width, bold_width = _parse_font(font_str) |
||
146 | # calculate the letter_spacing required to make bold have the same |
||
147 | # width as normal |
||
148 | self._bold_spacing = normal_width - bold_width |
||
149 | cell_pixel_width, cell_pixel_height = pixels |
||
150 | # calculate the total pixel width/height of the drawing area |
||
151 | pixel_width = cell_pixel_width * columns |
||
152 | pixel_height = cell_pixel_height * rows |
||
153 | gdkwin = da.get_window() |
||
154 | content = cairo.CONTENT_COLOR |
||
155 | self._cairo_surface = gdkwin.create_similar_surface(content, |
||
156 | pixel_width, |
||
157 | pixel_height) |
||
158 | self._cairo_context = cairo.Context(self._cairo_surface) |
||
159 | self._pango_layout = PangoCairo.create_layout(self._cairo_context) |
||
160 | self._pango_layout.set_alignment(Pango.Alignment.LEFT) |
||
161 | self._pango_layout.set_font_description(self._font) |
||
162 | self._pixel_width, self._pixel_height = pixel_width, pixel_height |
||
163 | self._cell_pixel_width = cell_pixel_width |
||
164 | self._cell_pixel_height = cell_pixel_height |
||
165 | self._screen = Screen(columns, rows) |
||
166 | self._window.resize(pixel_width, pixel_height) |
||
167 | |||
168 | def _nvim_clear(self): |
||
169 | self._clear_region(self._screen.top, self._screen.bot + 1, |
||
170 | self._screen.left, self._screen.right + 1) |
||
171 | self._screen.clear() |
||
172 | |||
173 | def _nvim_eol_clear(self): |
||
174 | row, col = self._screen.row, self._screen.col |
||
175 | self._clear_region(row, row + 1, col, self._screen.right + 1) |
||
176 | self._screen.eol_clear() |
||
177 | |||
178 | def _nvim_cursor_goto(self, row, col): |
||
179 | self._screen.cursor_goto(row, col) |
||
180 | |||
181 | def _nvim_busy_start(self): |
||
182 | self._busy = True |
||
183 | |||
184 | def _nvim_busy_stop(self): |
||
185 | self._busy = False |
||
186 | |||
187 | def _nvim_mouse_on(self): |
||
188 | self._mouse_enabled = True |
||
189 | |||
190 | def _nvim_mouse_off(self): |
||
191 | self._mouse_enabled = False |
||
192 | |||
193 | def _nvim_mode_change(self, mode): |
||
194 | self._insert_cursor = mode == 'insert' |
||
195 | |||
196 | def _nvim_set_scroll_region(self, top, bot, left, right): |
||
197 | self._screen.set_scroll_region(top, bot, left, right) |
||
198 | |||
199 | def _nvim_scroll(self, count): |
||
200 | self._flush() |
||
201 | top, bot = self._screen.top, self._screen.bot + 1 |
||
202 | left, right = self._screen.left, self._screen.right + 1 |
||
203 | # The diagrams below illustrate what will happen, depending on the |
||
204 | # scroll direction. "=" is used to represent the SR(scroll region) |
||
205 | # boundaries and "-" the moved rectangles. note that dst and src share |
||
206 | # a common region |
||
207 | if count > 0: |
||
208 | # move an rectangle in the SR up, this can happen while scrolling |
||
209 | # down |
||
210 | # +-------------------------+ |
||
211 | # | (clipped above SR) | ^ |
||
212 | # |=========================| dst_top | |
||
213 | # | dst (still in SR) | | |
||
214 | # +-------------------------+ src_top | |
||
215 | # | src (moved up) and dst | | |
||
216 | # |-------------------------| dst_bot | |
||
217 | # | src (cleared) | | |
||
218 | # +=========================+ src_bot |
||
219 | src_top, src_bot = top + count, bot |
||
220 | dst_top, dst_bot = top, bot - count |
||
221 | clr_top, clr_bot = dst_bot, src_bot |
||
222 | else: |
||
223 | # move a rectangle in the SR down, this can happen while scrolling |
||
224 | # up |
||
225 | # +=========================+ src_top |
||
226 | # | src (cleared) | | |
||
227 | # |------------------------ | dst_top | |
||
228 | # | src (moved down) and dst| | |
||
229 | # +-------------------------+ src_bot | |
||
230 | # | dst (still in SR) | | |
||
231 | # |=========================| dst_bot | |
||
232 | # | (clipped below SR) | v |
||
233 | # +-------------------------+ |
||
234 | src_top, src_bot = top, bot + count |
||
235 | dst_top, dst_bot = top - count, bot |
||
236 | clr_top, clr_bot = src_top, dst_top |
||
237 | self._cairo_surface.flush() |
||
238 | self._cairo_context.save() |
||
239 | # The move is performed by setting the source surface to itself, but |
||
240 | # with a coordinate transformation. |
||
241 | _, y = self._get_coords(dst_top - src_top, 0) |
||
242 | self._cairo_context.set_source_surface(self._cairo_surface, 0, y) |
||
243 | # Clip to ensure only dst is affected by the change |
||
244 | self._mask_region(dst_top, dst_bot, left, right) |
||
245 | # Do the move |
||
246 | self._cairo_context.paint() |
||
247 | self._cairo_context.restore() |
||
248 | # Clear the emptied region |
||
249 | self._clear_region(clr_top, clr_bot, left, right) |
||
250 | self._screen.scroll(count) |
||
251 | |||
252 | def _nvim_highlight_set(self, attrs): |
||
253 | self._attrs = self._get_pango_attrs(attrs) |
||
254 | |||
255 | def _nvim_put(self, text): |
||
256 | if self._screen.row != self._pending[0]: |
||
257 | # flush pending text if jumped to a different row |
||
258 | self._flush() |
||
259 | # work around some redraw glitches that can happen |
||
260 | self._redraw_glitch_fix() |
||
261 | # Update internal screen |
||
262 | self._screen.put(self._get_pango_text(text), self._attrs) |
||
263 | self._pending[1] = min(self._screen.col - 1, self._pending[1]) |
||
264 | self._pending[2] = max(self._screen.col, self._pending[2]) |
||
265 | |||
266 | def _nvim_bell(self): |
||
267 | self._window.get_window().beep() |
||
268 | |||
269 | def _nvim_visual_bell(self): |
||
270 | pass |
||
271 | |||
272 | def _nvim_update_fg(self, fg): |
||
273 | self._foreground = fg |
||
274 | self._reset_cache() |
||
275 | |||
276 | def _nvim_update_bg(self, bg): |
||
277 | self._background = bg |
||
278 | self._reset_cache() |
||
279 | |||
280 | def _nvim_suspend(self): |
||
281 | self._window.iconify() |
||
282 | |||
283 | def _nvim_set_title(self, title): |
||
284 | self._window.set_title(title) |
||
285 | |||
286 | def _nvim_set_icon(self, icon): |
||
287 | self._window.set_icon_name(icon) |
||
288 | |||
289 | def _gtk_draw(self, wid, cr): |
||
290 | if not self._screen: |
||
291 | return |
||
292 | # from random import random |
||
293 | # cr.rectangle(0, 0, self._pixel_width, self._pixel_height) |
||
294 | # cr.set_source_rgb(random(), random(), random()) |
||
295 | # cr.fill() |
||
296 | self._cairo_surface.flush() |
||
297 | cr.save() |
||
298 | cr.rectangle(0, 0, self._pixel_width, self._pixel_height) |
||
299 | cr.clip() |
||
300 | cr.set_source_surface(self._cairo_surface, 0, 0) |
||
301 | cr.paint() |
||
302 | cr.restore() |
||
303 | if not self._busy and self._blink: |
||
304 | # Cursor is drawn separately in the window. This approach is |
||
305 | # simpler because it doesn't taint the internal cairo surface, |
||
306 | # which is used for scrolling |
||
307 | row, col = self._screen.row, self._screen.col |
||
308 | text, attrs = self._screen.get_cursor() |
||
309 | self._pango_draw(row, col, [(text, attrs,)], cr=cr, cursor=True) |
||
310 | x, y = self._get_coords(row, col) |
||
311 | currect = Rectangle(x, y, self._cell_pixel_width, |
||
312 | self._cell_pixel_height) |
||
313 | self._im_context.set_cursor_location(currect) |
||
314 | |||
315 | def _gtk_configure(self, widget, event): |
||
316 | def resize(*args): |
||
317 | self._resize_timer_id = None |
||
318 | width, height = self._window.get_size() |
||
319 | columns = width / self._cell_pixel_width |
||
320 | rows = height / self._cell_pixel_height |
||
321 | if self._screen.columns == columns and self._screen.rows == rows: |
||
322 | return |
||
323 | self._bridge.resize(columns, rows) |
||
324 | |||
325 | if not self._screen: |
||
326 | return |
||
327 | if event.width == self._pixel_width and \ |
||
328 | event.height == self._pixel_height: |
||
329 | return |
||
330 | if self._resize_timer_id is not None: |
||
331 | GLib.source_remove(self._resize_timer_id) |
||
332 | self._resize_timer_id = GLib.timeout_add(250, resize) |
||
333 | |||
334 | def _gtk_quit(self, *args): |
||
335 | self._bridge.exit() |
||
336 | |||
337 | def _gtk_key(self, widget, event, *args): |
||
338 | # This function was adapted from pangoterm source code |
||
339 | keyval = event.keyval |
||
340 | state = event.state |
||
341 | # GtkIMContext will eat a Shift-Space and not tell us about shift. |
||
342 | # Also don't let IME eat any GDK_KEY_KP_ events |
||
343 | done = (False if state & SHIFT and keyval == ord(' ') else |
||
344 | False if Gdk.KEY_KP_Space <= keyval <= Gdk.KEY_KP_Divide else |
||
345 | self._im_context.filter_keypress(event)) |
||
346 | if done: |
||
347 | # input method handled keypress |
||
348 | return True |
||
349 | if event.is_modifier: |
||
350 | # We don't need to track the state of modifier bits |
||
351 | return |
||
352 | # translate keyval to nvim key |
||
353 | key_name = Gdk.keyval_name(keyval) |
||
354 | if key_name.startswith('KP_'): |
||
355 | key_name = key_name[3:] |
||
356 | input_str = _stringify_key(KEY_TABLE.get(key_name, key_name), state) |
||
357 | self._bridge.input(input_str) |
||
358 | |||
359 | def _gtk_key_release(self, widget, event, *args): |
||
360 | self._im_context.filter_keypress(event) |
||
361 | |||
362 | def _gtk_button_press(self, widget, event, *args): |
||
363 | if not self._mouse_enabled or event.type != Gdk.EventType.BUTTON_PRESS: |
||
364 | return |
||
365 | button = 'Left' |
||
366 | if event.button == 2: |
||
367 | button = 'Middle' |
||
368 | elif event.button == 3: |
||
369 | button = 'Right' |
||
370 | col = int(math.floor(event.x / self._cell_pixel_width)) |
||
371 | row = int(math.floor(event.y / self._cell_pixel_height)) |
||
372 | input_str = _stringify_key(button + 'Mouse', event.state) |
||
373 | input_str += '<{0},{1}>'.format(col, row) |
||
374 | self._bridge.input(input_str) |
||
375 | self._pressed = button |
||
376 | |||
377 | def _gtk_button_release(self, widget, event, *args): |
||
378 | self._pressed = None |
||
379 | |||
380 | def _gtk_motion_notify(self, widget, event, *args): |
||
381 | if not self._mouse_enabled or not self._pressed: |
||
382 | return |
||
383 | col = int(math.floor(event.x / self._cell_pixel_width)) |
||
384 | row = int(math.floor(event.y / self._cell_pixel_height)) |
||
385 | input_str = _stringify_key(self._pressed + 'Drag', event.state) |
||
386 | input_str += '<{0},{1}>'.format(col, row) |
||
387 | self._bridge.input(input_str) |
||
388 | |||
389 | def _gtk_scroll(self, widget, event, *args): |
||
390 | if not self._mouse_enabled: |
||
391 | return |
||
392 | col = int(math.floor(event.x / self._cell_pixel_width)) |
||
393 | row = int(math.floor(event.y / self._cell_pixel_height)) |
||
394 | if event.direction == Gdk.ScrollDirection.UP: |
||
395 | key = 'ScrollWheelUp' |
||
396 | elif event.direction == Gdk.ScrollDirection.DOWN: |
||
397 | key = 'ScrollWheelDown' |
||
398 | else: |
||
399 | return |
||
400 | input_str = _stringify_key(key, event.state) |
||
401 | input_str += '<{0},{1}>'.format(col, row) |
||
402 | self._bridge.input(input_str) |
||
403 | |||
404 | def _gtk_focus_in(self, *a): |
||
405 | self._im_context.focus_in() |
||
406 | |||
407 | def _gtk_focus_out(self, *a): |
||
408 | self._im_context.focus_out() |
||
409 | |||
410 | def _gtk_input(self, widget, input_str, *args): |
||
411 | self._bridge.input(input_str.replace('<', '<lt>')) |
||
412 | |||
413 | def _start_blinking(self): |
||
414 | def blink(*args): |
||
415 | self._blink = not self._blink |
||
416 | self._screen_invalid() |
||
417 | self._blink_timer_id = GLib.timeout_add(500, blink) |
||
418 | if self._blink_timer_id: |
||
419 | GLib.source_remove(self._blink_timer_id) |
||
420 | self._blink = False |
||
421 | blink() |
||
422 | |||
423 | def _clear_region(self, top, bot, left, right): |
||
424 | self._flush() |
||
425 | self._cairo_context.save() |
||
426 | self._mask_region(top, bot, left, right) |
||
427 | r, g, b = _split_color(self._background) |
||
428 | r, g, b = r / 255.0, g / 255.0, b / 255.0 |
||
429 | self._cairo_context.set_source_rgb(r, g, b) |
||
430 | self._cairo_context.paint() |
||
431 | self._cairo_context.restore() |
||
432 | |||
433 | def _mask_region(self, top, bot, left, right, cr=None): |
||
434 | if not cr: |
||
435 | cr = self._cairo_context |
||
436 | x1, y1, x2, y2 = self._get_rect(top, bot, left, right) |
||
437 | cr.rectangle(x1, y1, x2 - x1, y2 - y1) |
||
438 | cr.clip() |
||
439 | |||
440 | def _get_rect(self, top, bot, left, right): |
||
441 | x1, y1 = self._get_coords(top, left) |
||
442 | x2, y2 = self._get_coords(bot, right) |
||
443 | return x1, y1, x2, y2 |
||
444 | |||
445 | def _get_coords(self, row, col): |
||
446 | x = col * self._cell_pixel_width |
||
447 | y = row * self._cell_pixel_height |
||
448 | return x, y |
||
449 | |||
450 | def _flush(self): |
||
451 | row, startcol, endcol = self._pending |
||
452 | self._pending[0] = self._screen.row |
||
453 | self._pending[1] = self._screen.col |
||
454 | self._pending[2] = self._screen.col |
||
455 | if startcol == endcol: |
||
456 | return |
||
457 | self._cairo_context.save() |
||
458 | ccol = startcol |
||
459 | buf = [] |
||
460 | bold = False |
||
461 | for _, col, text, attrs in self._screen.iter(row, row, startcol, |
||
462 | endcol - 1): |
||
463 | newbold = attrs and 'bold' in attrs[0] |
||
464 | if newbold != bold or not text: |
||
465 | if buf: |
||
466 | self._pango_draw(row, ccol, buf) |
||
467 | bold = newbold |
||
468 | buf = [(text, attrs,)] |
||
469 | ccol = col |
||
470 | else: |
||
471 | buf.append((text, attrs,)) |
||
472 | if buf: |
||
473 | self._pango_draw(row, ccol, buf) |
||
474 | self._cairo_context.restore() |
||
475 | |||
476 | def _pango_draw(self, row, col, data, cr=None, cursor=False): |
||
477 | markup = [] |
||
478 | for text, attrs in data: |
||
479 | if not attrs: |
||
480 | attrs = self._get_pango_attrs(None) |
||
481 | attrs = attrs[1] if cursor else attrs[0] |
||
482 | markup.append('<span {0}>{1}</span>'.format(attrs, text)) |
||
483 | markup = ''.join(markup) |
||
484 | self._pango_layout.set_markup(markup, -1) |
||
485 | # Draw the text |
||
486 | if not cr: |
||
487 | cr = self._cairo_context |
||
488 | x, y = self._get_coords(row, col) |
||
489 | if cursor and self._insert_cursor: |
||
490 | cr.rectangle(x, y, self._cell_pixel_width / 4, |
||
491 | self._cell_pixel_height) |
||
492 | cr.clip() |
||
493 | cr.move_to(x, y) |
||
494 | PangoCairo.update_layout(cr, self._pango_layout) |
||
495 | PangoCairo.show_layout(cr, self._pango_layout) |
||
496 | _, r = self._pango_layout.get_pixel_extents() |
||
497 | |||
498 | def _get_pango_text(self, text): |
||
499 | rv = self._pango_text_cache.get(text, None) |
||
500 | if rv is None: |
||
501 | rv = GLib.markup_escape_text(text or '') |
||
502 | self._pango_text_cache[text] = rv |
||
503 | return rv |
||
504 | |||
505 | def _get_pango_attrs(self, attrs): |
||
506 | key = tuple(sorted((k, v,) for k, v in (attrs or {}).items())) |
||
507 | rv = self._pango_attrs_cache.get(key, None) |
||
508 | if rv is None: |
||
509 | fg = self._foreground if self._foreground != -1 else 0 |
||
510 | bg = self._background if self._background != -1 else 0xffffff |
||
511 | n = { |
||
512 | 'foreground': _split_color(fg), |
||
513 | 'background': _split_color(bg), |
||
514 | } |
||
515 | if attrs: |
||
516 | # make sure that foreground and background are assigned first |
||
517 | for k in ['foreground', 'background']: |
||
518 | if k in attrs: |
||
519 | n[k] = _split_color(attrs[k]) |
||
520 | for k, v in attrs.items(): |
||
521 | if k == 'reverse': |
||
522 | n['foreground'], n['background'] = \ |
||
523 | n['background'], n['foreground'] |
||
524 | elif k == 'italic': |
||
525 | n['font_style'] = 'italic' |
||
526 | elif k == 'bold': |
||
527 | n['font_weight'] = 'bold' |
||
528 | if self._bold_spacing: |
||
529 | n['letter_spacing'] = str(self._bold_spacing) |
||
530 | elif k == 'underline': |
||
531 | n['underline'] = 'single' |
||
532 | c = dict(n) |
||
533 | c['foreground'] = _invert_color(*_split_color(fg)) |
||
534 | c['background'] = _invert_color(*_split_color(bg)) |
||
535 | c['foreground'] = _stringify_color(*c['foreground']) |
||
536 | c['background'] = _stringify_color(*c['background']) |
||
537 | n['foreground'] = _stringify_color(*n['foreground']) |
||
538 | n['background'] = _stringify_color(*n['background']) |
||
539 | n = ' '.join(['{0}="{1}"'.format(k, v) for k, v in n.items()]) |
||
540 | c = ' '.join(['{0}="{1}"'.format(k, v) for k, v in c.items()]) |
||
541 | rv = (n, c,) |
||
542 | self._pango_attrs_cache[key] = rv |
||
543 | return rv |
||
544 | |||
545 | def _reset_cache(self): |
||
546 | self._pango_text_cache = {} |
||
547 | self._pango_attrs_cache = {} |
||
548 | |||
549 | def _redraw_glitch_fix(self): |
||
550 | row, col = self._screen.row, self._screen.col |
||
551 | text, attrs = self._screen.get_cursor() |
||
552 | # when updating cells in italic or bold words, the result can become |
||
553 | # messy(characters can be clipped or leave remains when removed). To |
||
554 | # prevent that, always update non empty sequences of cells and the |
||
555 | # surrounding space. |
||
556 | # find the start of the sequence |
||
557 | lcol = col - 1 |
||
558 | while lcol >= 0: |
||
559 | text, _ = self._screen.get_cell(row, lcol) |
||
560 | lcol -= 1 |
||
561 | if text == ' ': |
||
562 | break |
||
563 | self._pending[1] = min(lcol + 1, self._pending[1]) |
||
564 | # find the end of the sequence |
||
565 | rcol = col + 1 |
||
566 | while rcol < self._screen.columns: |
||
567 | text, _ = self._screen.get_cell(row, rcol) |
||
568 | rcol += 1 |
||
569 | if text == ' ': |
||
570 | break |
||
571 | self._pending[2] = max(rcol, self._pending[2]) |
||
572 | |||
612 |
This can be caused by one of the following:
1. Missing Dependencies
This error could indicate a configuration issue of Pylint. Make sure that your libraries are available by adding the necessary commands.
2. Missing __init__.py files
This error could also result from missing
__init__.py
files in your module folders. Make sure that you place one file in each sub-folder.