Passed
Push — master ( 5418a4...879c1c )
by torrua
01:18
created

keyboa.keyboards._keyboa_pre_check()   B

Complexity

Conditions 6

Size

Total Lines 37
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 6

Importance

Changes 0
Metric Value
eloc 15
dl 0
loc 37
ccs 10
cts 10
cp 1
rs 8.6666
c 0
b 0
f 0
cc 6
nop 3
crap 6
1
# -*- coding:utf-8 -*-
2 1
"""
3
This module contains all the necessary functions for
4
creating complex and functional inline keyboards.
5
"""
6
7 1
from collections.abc import Iterable
8 1
from typing import Union, List, Optional, Tuple
9
10 1
from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton
11
12
# pylint: disable=R0913
13
14 1
InlineButtonData = Union[str, int, tuple, dict, InlineKeyboardButton]
15
16 1
button_text_types = (str, int)
17 1
ButtonText = Union[button_text_types]
18
19 1
callback_data_types = (str, int, type(None))
20 1
CallbackDataMarker = Union[callback_data_types]
21
22
# structureless sequence of InlineButtonData objects
23 1
FlatSequence = List[InlineButtonData]
24
25
# structured sequence of InlineButtonData objects
26 1
StructuredSequence = List[Union[FlatSequence, InlineButtonData]]
27
28
# unified type that allows you to use any available data types for the keyboard
29 1
BlockItems = Union[StructuredSequence, InlineButtonData]
30
31 1
MAXIMUM_ITEMS_IN_KEYBOARD = 100
32 1
MAXIMUM_ITEMS_IN_LINE = 8
33 1
MINIMUM_ITEMS_IN_LINE = 1
34 1
DEFAULT_ITEMS_IN_LINE = MINIMUM_ITEMS_IN_LINE
35 1
AUTO_ALIGNMENT_RANGE = range(3, 6)
36 1
MAXIMUM_CBD_LENGTH = 64
37
38
39 1
def _keyboa_pre_check(
40
        items: BlockItems = None,
41
        items_in_row: int = None,
42
        keyboard: InlineKeyboardMarkup = None) -> None:
43
    """
44
    This function checks whether the keyboard parameters are beyond Telegram limits or not.
45
46
    :param items: InlineRowItems - Sequence of elements with optional structure,
47
        where each top-level item will be a row with one or several buttons.
48
49
    :param items_in_row: int - Desired number of buttons in one row. Should be from 1 to 8.
50
        Optional. The default value is None.
51
52
    :param keyboard: InlineKeyboardMarkup object to which we will attach the list items.
53
        We need to count the existing buttons so as not to go beyond the general limits.
54
        Optional. The default value is None.
55
56
    :return: None if everything is okay.
57
58
    Covered by tests.
59
    """
60
61 1
    if items is None:
62 1
        return
63
64 1
    if items and not isinstance(items, list):
65 1
        items = [items, ]
66
67 1
    if keyboard and not isinstance(keyboard, InlineKeyboardMarkup):
68 1
        type_error_message = \
69
            "Keyboard to which the new items will be added " \
70
            "should have InlineKeyboardMarkup type. Now it is a %s" % type(keyboard)
71 1
        raise TypeError(type_error_message)
72
73
    # We need to count existing buttons too if we passed keyboard object to the function
74 1
    items_in_keyboard = get_total_items_number(items, keyboard)
75 1
    check_keyboard_items_limits(items_in_keyboard, items_in_row)
76
77
78 1
def check_keyboard_items_limits(items_in_keyboard: int, items_in_row: int) -> None:
79
    """
80
    :param items_in_keyboard:
81
    :param items_in_row:
82
    :return:
83
    """
84
85 1
    if items_in_keyboard > MAXIMUM_ITEMS_IN_KEYBOARD:
86 1
        value_error_message_keyboard = \
87
            "Telegram Bot API limit exceeded: The keyboard should have " \
88
            "from 1 to %s buttons at all. Your total amount is %s."
89 1
        raise ValueError(value_error_message_keyboard %
90
                         (MAXIMUM_ITEMS_IN_KEYBOARD, items_in_keyboard))
91
92 1
    if items_in_row is not None and \
93
            (MINIMUM_ITEMS_IN_LINE > items_in_row or items_in_row > MAXIMUM_ITEMS_IN_LINE):
94 1
        value_error_message_line = \
95
            "Telegram Bot API limit exceeded: " \
96
            "The keyboard line should have from 1 to %s buttons. You entered %s."
97 1
        raise ValueError(value_error_message_line %
98
                         (MAXIMUM_ITEMS_IN_LINE, items_in_row))
99
100
101 1
def get_total_items_number(items, keyboard) -> int:
102
    """
103
    :param items:
104
    :param keyboard:
105
    :return:
106
    """
107 1
    total_items_number = sum(
108
        len(row) if isinstance(row, (list, tuple, set)) else 1 for row in items)
109
110 1
    if not keyboard:
111 1
        return total_items_number
112
113 1
    keyboard_items = keyboard.__dict__['keyboard']
114 1
    current_keyboard_items_number = sum(len(row) for row in keyboard_items)
115 1
    return total_items_number + current_keyboard_items_number
116
117
118 1
def button_maker(
119
        button_data: InlineButtonData,
120
        front_marker: CallbackDataMarker = str(),
121
        back_marker: CallbackDataMarker = str(),
122
        copy_text_to_callback: bool = False,
123
) -> InlineKeyboardButton:
124
    """
125
    This function creates an InlineKeyboardButton object from various data types,
126
    such as str, int, tuple, dict.
127
128
    :param button_data: InlineButtonData - an object from which the button will be created:
129
    • If string or an integer, it will be used for both text and callback.
130
    • If tuple, the zero element [0] will be the text, and the first [1] will be the callback.
131
    • If dictionary, there are two options:
132
        > if there is no "text" key in dictionary and only one key exists,
133
            the key will be the text, and the value will be the callback.
134
            In this case, no verification of the dictionary's contents is performed!
135
        > if "text" key exists, function passes the whole dictionary to InlineKeyboardButton,
136
            where dictionary's keys represent object's parameters
137
            and dictionary's values represent parameters' values accordingly.
138
        In all other cases ValueError will be called.
139
140
    :param front_marker: CallbackDataMarker - a string to be added to the left side of callback.
141
        Optional. The default value is None.
142
143
    :param back_marker: CallbackDataMarker - a string to be added to the right side of callback.
144
        Optional. The default value is None.
145
146
    :param copy_text_to_callback: If enabled and button_data is a string or integer,
147
        function will copy button text to callback data (and add markers if they exist).
148
        Optional. The default value is False.
149
150
    :return: InlineKeyboardButton
151
152
    Covered by tests.
153
    """
154
155 1
    if isinstance(button_data, InlineKeyboardButton):
156 1
        return button_data
157
158 1
    if isinstance(button_data, dict) and button_data.get("text"):
159 1
        return InlineKeyboardButton(**button_data)
160
161 1
    button_tuple = get_button_tuple(button_data, copy_text_to_callback)
162
163 1
    text = get_text(button_tuple)
164 1
    raw_callback = get_callback(button_tuple)
165 1
    callback_data = get_callback_data(raw_callback, front_marker, back_marker)
166
167 1
    prepared_button = {"text": text, "callback_data": callback_data}
168
169 1
    return InlineKeyboardButton(**prepared_button)
170
171
172 1
def get_callback_data(
173
        raw_callback: CallbackDataMarker,
174
        front_marker: CallbackDataMarker = str(),
175
        back_marker: CallbackDataMarker = str()) -> str:
176
    """
177
    :param raw_callback:
178
    :param front_marker:
179
    :param back_marker:
180
    :return:
181
    """
182 1
    if front_marker is None:
183 1
        front_marker = str()
184 1
    if back_marker is None:
185 1
        back_marker = str()
186 1
    for marker in (front_marker, back_marker):
187 1
        if not isinstance(marker, callback_data_types):
188 1
            type_error_message = \
189
                "Marker could not have %s type. Only %s allowed." \
190
                % (type(marker), CallbackDataMarker)
191 1
            raise TypeError(type_error_message)
192 1
    callback_data = "%s%s%s" % (front_marker, raw_callback, back_marker)
193
194 1
    if not callback_data:
195 1
        raise ValueError("The callback data cannot be empty.")
196
197 1
    if len(callback_data.encode()) > MAXIMUM_CBD_LENGTH:
198 1
        size_error_message = "The callback data cannot be more than " \
199
                             "64 bytes for one button. Your size is %s" \
200
                             % len(callback_data.encode())
201 1
        raise ValueError(size_error_message)
202
203 1
    return callback_data
204
205
206 1
def get_callback(button_data: tuple) -> str:
207
    """
208
    :param button_data:
209
    :return:
210
    """
211 1
    callback = button_data[1]
212 1
    if not isinstance(callback, callback_data_types):
213 1
        type_error_message = "Callback cannot be %s. Only %s allowed." \
214
                             % (type(callback), callback_data_types)
215 1
        raise TypeError(type_error_message)
216 1
    return callback
217
218
219 1
def get_text(button_data: tuple) -> str:
220
    """
221
    :param button_data:
222
    :return:
223
    """
224 1
    raw_text = button_data[0]
225 1
    if not isinstance(raw_text, button_text_types):
226 1
        type_error_message = "Button text cannot be %s. Only %s allowed." \
227
                             % (type(raw_text), ButtonText)
228 1
        raise TypeError(type_error_message)
229 1
    text = str(raw_text)
230 1
    if not text:
231 1
        raise ValueError("Button text cannot be empty.")
232 1
    return text
233
234
235 1
def get_button_tuple(button_data: InlineButtonData, copy_text_to_callback: bool) -> tuple:
236
    """
237
    :param button_data:
238
    :param copy_text_to_callback:
239
    :return:
240
    """
241 1
    if not isinstance(button_data, (tuple, dict, str, int)):
242 1
        type_error_message = \
243
            "Cannot create %s from %s. Please use %s instead.\n" \
244
            "Probably you specified 'auto_alignment' or 'items_in_line' " \
245
            "parameter for StructuredSequence." \
246
            % (InlineKeyboardButton, type(button_data), InlineButtonData)
247 1
        raise TypeError(type_error_message)
248 1
    if isinstance(button_data, (str, int)):
249 1
        btn_tuple = button_data, button_data if copy_text_to_callback else str()
250
251 1
    elif isinstance(button_data, dict):
252 1
        if len(button_data.keys()) != 1:
253 1
            value_type_error = \
254
                "Cannot convert dictionary to InlineButtonData object. " \
255
                "You passed more than one item, but did not add 'text' key."
256 1
            raise ValueError(value_type_error)
257
258 1
        btn_tuple = next(iter(button_data.items()))
259
    else:
260 1
        btn_tuple = button_data
261
262 1
    if len(btn_tuple) == 1 or btn_tuple[1] is None:
263 1
        btn_tuple = btn_tuple[0], btn_tuple[0] if copy_text_to_callback else str()
264 1
    return btn_tuple
265
266
267 1
def keyboa_maker(
268
        items: BlockItems = None,
269
        front_marker: CallbackDataMarker = None,
270
        back_marker: CallbackDataMarker = None,
271
272
        items_in_row: int = None,
273
        auto_alignment: Union[bool, Iterable] = False,
274
        reverse_alignment_range: bool = False,
275
        slice_start: int = None,
276
        slice_stop: int = None,
277
        slice_step: int = None,
278
279
        copy_text_to_callback: bool = False,
280
        add_to_keyboard: InlineKeyboardMarkup = None,
281
) -> InlineKeyboardMarkup:
282
    """
283
    :param items:
284
    :param front_marker:
285
    :param back_marker:
286
    :param items_in_row:
287
    :param auto_alignment:
288
    :param reverse_alignment_range:
289
    :param slice_start:
290
    :param slice_stop:
291
    :param slice_step:
292
    :param copy_text_to_callback:
293
    :param add_to_keyboard:
294
    :return:
295
    """
296 1
    keyboard = add_to_keyboard if add_to_keyboard else InlineKeyboardMarkup()
297
298 1
    if items is None:
299 1
        return keyboard
300
301 1
    if items and not isinstance(items, list):
302 1
        items = [items, ]
303
304 1
    items = items[slice_start:slice_stop:slice_step] if items else items
305
306 1
    _keyboa_pre_check(items=items, items_in_row=items_in_row, keyboard=keyboard)
307
308 1
    if items_in_row or auto_alignment:
309 1
        return get_generated_keyboard(
310
            items, front_marker, back_marker, items_in_row, auto_alignment,
311
            reverse_alignment_range, copy_text_to_callback, keyboard)
312
313 1
    return get_preformatted_keyboard(
314
        items, front_marker, back_marker,
315
        copy_text_to_callback, keyboard)
316
317
318 1
def get_preformatted_keyboard(
319
        items, front_marker, back_marker,
320
        copy_text_to_callback, keyboard):
321
    """
322
    :param items:
323
    :param front_marker:
324
    :param back_marker:
325
    :param copy_text_to_callback:
326
    :param keyboard:
327
    :return:
328
    """
329 1
    for index, item in enumerate(items):
330 1
        if not isinstance(item, list):
331 1
            items[index] = [item, ]
332 1
    for row in items:
333 1
        buttons = [button_maker(
334
            button_data=item,
335
            front_marker=front_marker,
336
            back_marker=back_marker,
337
            copy_text_to_callback=copy_text_to_callback
338
        ) for item in row]
339 1
        keyboard.row(*buttons)
340 1
    return keyboard
341
342
343 1
def get_generated_keyboard(
344
        items, front_marker, back_marker, items_in_row,
345
        auto_alignment, reverse_alignment_range,
346
        copy_text_to_callback, keyboard):
347
    """
348
    :param items:
349
    :param front_marker:
350
    :param back_marker:
351
    :param items_in_row:
352
    :param auto_alignment:
353
    :param reverse_alignment_range:
354
    :param copy_text_to_callback:
355
    :param keyboard:
356
    :return:
357
    """
358
359 1
    if auto_alignment:
360 1
        items_in_row = calculate_items_in_row(items, auto_alignment, reverse_alignment_range)
361
362 1
    if not items_in_row:
363 1
        items_in_row = DEFAULT_ITEMS_IN_LINE
364
365 1
    rows_in_keyboard = (len(items) // items_in_row)
366 1
    buttons = [button_maker(
367
        button_data=item,
368
        front_marker=front_marker,
369
        back_marker=back_marker,
370
        copy_text_to_callback=copy_text_to_callback,
371
    ) for item in items]
372 1
    for _row in range(rows_in_keyboard):
373 1
        keyboard.row(*[buttons.pop(0) for _button in range(items_in_row)])
374 1
    keyboard.row(*buttons)
375 1
    return keyboard
376
377
378 1
def calculate_items_in_row(items, auto_alignment, reverse_alignment_range) -> Optional[int]:
379
    """
380
    :param items:
381
    :param auto_alignment:
382
    :param reverse_alignment_range:
383
    :return:
384
    """
385
386 1
    items_in_row = None
387 1
    alignment_range = get_alignment_range(auto_alignment)
388
389 1
    if reverse_alignment_range:
390 1
        alignment_range = reversed(alignment_range)
391
392 1
    for divider in alignment_range:
393 1
        if not len(items) % divider:
394 1
            items_in_row = divider
395 1
            break
396
397 1
    return items_in_row
398
399
400 1
def get_alignment_range(auto_alignment):
401
    """
402
    :param auto_alignment:
403
    :return:
404
    """
405
406 1
    if isinstance(auto_alignment, bool):
407 1
        return AUTO_ALIGNMENT_RANGE
408
409 1
    if not (isinstance(auto_alignment, Iterable)
410
            and all(map(lambda s: isinstance(s, int), auto_alignment))):
411 1
        type_error_message = \
412
            "The auto_alignment variable has not a proper type. " \
413
            "Only Iterable of integers or boolean type allowed.\n" \
414
            "You may define it as 'True' to use AUTO_ALIGNMENT_RANGE."
415 1
        raise TypeError(type_error_message)
416
417 1
    if max(auto_alignment) > MAXIMUM_ITEMS_IN_LINE \
418
            or min(auto_alignment) < MINIMUM_ITEMS_IN_LINE:
419 1
        value_error_message = \
420
            "The auto_alignment's item values should be between " \
421
            "%s and %s. You entered: %s\n" \
422
            "You may define it as 'True' to use AUTO_ALIGNMENT_RANGE." \
423
            % (MINIMUM_ITEMS_IN_LINE, MAXIMUM_ITEMS_IN_LINE, auto_alignment)
424 1
        raise ValueError(value_error_message)
425
426 1
    return auto_alignment
427
428
429 1
def keyboa_combiner(
430
        keyboards: Optional[Union[Tuple[InlineKeyboardMarkup, ...], InlineKeyboardMarkup]] = None
431
) -> InlineKeyboardMarkup:
432
    """
433
    This function combines multiple InlineKeyboardMarkup objects into one.
434
435
    :param keyboards: Sequence of InlineKeyboardMarkup objects.
436
        Also could be presented as a standalone InlineKeyboardMarkup.
437
438
    :return: InlineKeyboardMarkup
439
    """
440
441 1
    if keyboards is None:
442 1
        return InlineKeyboardMarkup()
443
444 1
    if isinstance(keyboards, InlineKeyboardMarkup):
445 1
        keyboards = (keyboards, )
446
447 1
    data = []
448 1
    for keyboard in keyboards:
449 1
        if keyboard is None:
450 1
            continue
451
452 1
        if not isinstance(keyboard, InlineKeyboardMarkup):
453 1
            type_error_message = \
454
                "Keyboard element cannot be %s. Only InlineKeyboardMarkup allowed." \
455
                % type(keyboard)
456 1
            raise TypeError(type_error_message)
457
458 1
        data.extend(keyboard.keyboard)
459
460
    return keyboa_maker(data)
461