Passed
Push — master ( ae0edc...33d86d )
by torrua
01:27
created

keyboa.keyboards.get_preformatted_keyboard()   A

Complexity

Conditions 4

Size

Total Lines 23
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 4

Importance

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