Python Заметки
2.31K subscribers
58 photos
2 videos
2 files
212 links
Интересные заметки и обучающие материалы по Python

Контакт: @paulwinex

⚠️ Рекламу на канале не делаю!⚠️

Хештеги для поиска:
#tricks
#libs
#pep
#basic
#regex
#qt
#django
#2to3
#source
#offtop
Download Telegram
Чем вы измеряете время?
Обычно, когда мы хотим измерить время выполнения функции, мы пишем так:

import time
start_time = time.time()
execute_something()
print('Time:', time.time()-start_time)

Всё верно, мы посчитаем время выполнения с точностью до долей секунды. Но данный способ не даёт 100% гарантии правильного расчёта. Почему?

Дело в том, что метод time() возвращает системное время. Допустим, мы начали замер времени, сохранив текущее время. А во время выполнения функции кто-то зашел и переставил системные часы на час назад (например автоматическая синхронизация времени или переход на зимнее\летнее время). Когда функция завершится, мы вполне можем получить отрицательное время!

Это очевидный фейл. Поэтому, для таких случаев есть специальный метод time.monotonic(). В описании метода ясно написано, что это время не может идти назад, так как это относительное время. Именно этот метод будет делать правильные изменения.

Но самый правильный способ замера производительности это метод time.perf_counter(). Он даёт максимально возможный точный замер времени (меньше наносекунды). Полезно для профайлинга очень быстрых функций.
Итого, наш тест будет выглядеть так:

start_time = time.perf_counter()
execute_something()
print('Time:', time.perf_counter()-start_time)

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Про эти функции можно почитать в PEP418

Возможно, кто-то привык использовать время метод time.clock(), то есть время работы программы с момента старта. Учтите, что этот метод устарел и начиная с Python 3.8 будет удалён.

#tricks
Кеширование, это способ оптимизировать скорость программы за счёт повторного переиспользования рассчитанных данных. При этом инвалидация кеша вопрос сложный и неоднозначный. Тем не менее, есть ряд простых случаев, когда кеш вполне уместен.

Имеется функция, которая принимает аргументы и возвращает некое значение. При одинаковых аргументах результат всегда одинаковый.
Давайте приведём такой очевидный пример:

def add(a, b):
return a + b

Каждому понятно, что, если в эту функцию мы будем отправлять одни и те же данные, мы будем получать одинаковый результат, это важно. Тогда зачем нам каждый раз это пересчитывать? Давайте кешировать!
Для этого используем готовое решение из стандартной библиотеки functools.lru_cache() (Python3.4+)

from functools import lru_cache
import time

@lru_cache(maxsize=64)
def add(a, b):
time.sleep(1)
return a + b

Добавил задержку чтобы имитировать расчёты. Параметр декоратора maxsize указывает сколько именно разных пар аргументы-результат мы будем хранить.
Теперь вызываем функцию с замером времени. Используем массив с повторяющимися значениями

>>> for i in [1, 2, 3, 2, 1, 4]:
>>> start = time.perf_counter()
>>> add(2, i)
>>> end = time.perf_counter()-start
>>> print(f'i={i}, Time={end}')

i=1, Time=1.0007981109
i=2, Time=1.0008854520
i=3, Time=1.0008842469
i=2, Time=0.0000204799 # из кеша
i=1, Time=0.0000132510 # из кеша
i=4, Time=1.0008038339

В распечатке времени видно, как только входящие данные повторяются, вместо пересчёта нам возвращается готовое значение из кеша.

Такое кеширование будет неверным, если результат зависит не только от входящих данных. Учитывайте это!

#tricks
В предыдущем посте⬆️ был пример кеширования функции. В стандартной библиотеке есть еще один способ, но для классов.
Это functools.cached_property (Python3.8+). Логика работы точно такая же, но:

- он предназначен только для метода класса.
- аналогичен декоратору property, то есть мы получим не функцию а свойство класса.
- не принимает аргументов (кроме self), так же как и property.
- кеш сохраняет в атрибутах класса (внутри ˍˍdictˍˍ).

Эдакий частный случай lru_cache для класса. В результате, вместо такой записи

class MyClass:
@property
@functools.lru_cache(maxsize=1)
def value(self):
return 123

мы можем записать более красиво и адаптированно для property

from functools import cached_property

class MyClass:
@cached_property
def value(self):
return 123

Помимо более логичной записи, этот декоратор решает еще ряд проблем, возникающих при декорирвовании свойств классов через lru_cache. Так что в таких случаях его использование не то чтобы желательно, а обязательно!

#tricks
👍1
Правильно ли вы используете аргумент shell у методов subprocess?
Вкратце опишу разницу состояний этого аргумента.
(полный разбор — тема для статьи в блоге, возможно позже)

Флаг "shell" определяет, будет ли использоваться системный шел как основной исполняемый файл для вызова вашей команды.

shell=True
- К вашей команде добавится исполняемый файл /bin/sh или cmd.exe
- Ваша команда будет аргументом флага -с, поэтому команду нужно передавать строкой
- Команду необходимо передавать с готовым экранированием и лексическим разбором пробелов.

Правильно:
subprocess.check_output('ls -sl', shell=True)
Команда выглядит так:
/bin/sh -c "ls -sl"
Здесь видно, что мы передаём значение аргумента -c а не саму команду.

Неправильно
subprocess.check_output(['ls', '-sl'], shell=True)
Команда выглядит так:
/bin/sh -c "ls" -sl
Ошибки не будет, но аргументы используются неверно. Получите не то что ожидаете.

shell=False
- команду нужно передать списком
- ожидается, что первым аргументом будет исполняемый файл
- будет запущен непосредственно файл из первого аргумента, без /bin/sh или cmd.exe
- автоматическое экранирование пробелов в аргументах списка

Правильно
subprocess.check_output(['ls', '-sl'], shell=False) 
Команда будет выглядеть так
ls -sl

Неправильно
subprocess.check_output('ls -sl', shell=False)
Эта команда завершится ошибкой: No such file or directory
То есть система пытается найти файл "ls -sl" а не файл "ls"

А также, если не используется shell то путь к исполняемому файлу требуется писать абсолютным, даже стандартные системные утилиты.
___________________
- для Windows всё аналогично
- для других методов из subprocess всё аналогично

#tricks #libs
Дополнение к посту про shell в subprocess.

Чем полезен режим вызова через shell? То есть, когда вы ставите аргумент shell=True.
Ваша команда запустится не напрямую, а через системный шел. А это значит что доступны все возможности шела.

Например:
- распаковка пути с символом "~"
subprocess.check_output('ls ~/', shell=True)

- распаковка переменных окружения
subprocess.check_output('ls $HOME', shell=True)

- использование пайпа команд
subprocess.check_output('cat $HOME/output.log | grep -n error', shell=True))

В общем, те, кто активно использует терминал, могут остальное додумать сами 😉

#tricks
👍1
У словаря есть полезный метод get() который может "аккуратно" спросить значение по ключу и вернуть что-то по умолчанию если такого ключа не нашлось.
Я встречал два способа записать эту логику, очень похожие но имеющие серьезную разницу.
По умолчанию, если ключа нет в словаре, метод возвращает None или то что указано в аргументе default.

>>> my_dict.get('unknown_key', default=123)
123
>>> my_dict.get('unknown_key')
None

Нас интересует первый вариант. Его можно записать еще и таким способом

>>> my_dict.get('unknown_key') or 123

Чем он отличается от варианта с аргументом default? На первый взгляд ничем.
Если ключ не существует, то вернется None. Сработает оператор or и мы получим значение 123.
Но основная опасность кроется в операторе or! Дело в том, что значение из аргумента default вернется только если КЛЮЧ ОТСУТСТВУЕТ В СЛОВАРЕ. Если ключ найден, то вернется его значение.

Если же мы пишем вариантом с or, то правила меняется. Значение 123 мы получим если ключ отсутствует в словаре или если найденное значение равно False в виде bool. Например, если ключ всё же был найден но значение 0, мы всё равно получим 123, несмотря на то, что 0 может быть вполне валидным значением.


>>> d = {"key": 0}
>>> d.get("key", 5)
0
>>> d.get("key") or 5
5
# во втором случае неоднозначный результат



⚠️ Будьте внимательны! Точно представляйте, что вы хотите получить от своего кода.

#tricks
🥚 Забавная пасхалка
Попытка заглянуть в будущее и узнать, появятся ли в Python фигурный скобки даёт вполне чёткий ответ

from __future__ import braces
File "<input>", line 1
SyntaxError: not a chance
Все мы знаем, что в Python всё является объектом. Это значит, что всё можно сохранить в переменную, передать аргументом или вернуть из функции через return.
Но известно ли вам, что объектом можно сделать даже срез списка?! То есть сохранить в переменную алгоритм среза и применить его позже.
Это можно сделать с помощью builtin функции slice().

Для примера возмем простой список

>>> array = list(range(10))

Теперь создадим несколько срезов

>>> half = slice(None, len(array)//2)
>>> step_by_2 = slice(None, None, 2)
>>> invert = slice(None, None, -1)

Используется очень просто

>>> array[half]
[0, 1, 2, 3, 4]
>>> array[step_by_2]
[0, 2, 4, 6, 8]
>>> array[invert]
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]


#tricks
Сколько вы знаете базовых "легальных" способов форматирования строк с помощью переменных в Python? Я насчитал 5!

1️⃣ Оператор +

>>> greting = 'Hello'
>>> name = 'World'
>>> print(greting + ' ' + name)
'Hello World'

Самый не актуальный способ форматирования. С мелкими строками в количестве двух штук он работает быстрее всех. Но когда строки становятся длинной в сотни символов, этот метод просаживает производительность.
Делов том, что каждый оператор "+" выполняется отдельно для пар переменных, создавая новый объект строки, после которого следует следующий оператор, создающий еще один объект итд... В общем не рекомендуется, кроме случаев, где он явно выигрывает по скорости.

2️⃣ Оператор %

>>> name = 'World'
>>> print('Hello %s' % name)
'Hello World'

Быстрый, но не удобный. Устарел.

3️⃣ Метод str.format()
Есть много фишек применения этого метода. Для примера используем самый простой.

>>> name = 'World'
>>> print('Hello {}'.format(name))
'Hello World'

Наверное, самый функциональный метод с множеством возможностей. Рекомендован к использованию.

4️⃣ f-string

>>> name = 'World'
>>> print(f'Hello {name}')
'Hello World'

Относительно новый и удобный по синтаксису метод. Работает быстрей чем format(). Поддерживает аналогичные фишки форматирования (но не все). Еще развивается и обновляется в новых версиях Python.
Рекомендован.

5️⃣ string.Template

templ = string.Template('Hello $name') print(templ.substitute(name='World'))
'Hello World'

Самый медленный, но самый безопасный способ собрать строку. Он в 10 раз медленней самого медленного способа. Но никаких инлайн-экспрешенов или фигурных скобочек.
__________
Есть еще ряд других модулей, такие как textwrap, jinja или собственные методы строки. Но данный пост о простых способах вставки переменных в строку.

#tricks
Скорее всего уже слышали, что складывать строки через + это плохая практика. Падение производительности, и всё такое. Без лишних слов, давайте измерять:

from timeit import timeit

def t1():
# складываем 10 строк через + из переменной
t = 'text'
for _ in range(1000):
s = t + t + t + t + t + t + t + t + t

def t2():
# склеиваем список строк через метод join
arr = ['text'] * 10
for _ in range(1000):
s = ''.join(arr)

def t3():
# складываем через + но не из переменной а непосредственно инлайн объекты
for _ in range(1000):
s = 'text' + 'text' + 'text' +
... # всего 10 раз

Теперь каждую строку склейки запустим по 10М раз

>>> timeit(t1, number=10000)
0.21951690399964718
>>> timeit(t2, number=10000)
1.4978306379998685
>>> timeit(t3, number=10000)
0.2213820789993406

Хм, а нам говорили что через "+" это плохо и медленно ))) 😁
Тут стоит учитывать, что речь идёт о склейке множества длинных строк.
Давайте изменим условия:

def t4():
t = 'text'*100
for _ in range(1000):
s = t + t + t + t + t + t + t + t + t

def t5():
arr = ['text'*100] * 10
for _ in range(1000):
s = ''.join(arr)

def t6():
for _ in range(1000):
s = 'text'*100 + 'text'*100 + ... # всего 10 раз


>>> timeit(t4, number=10000)
12.795130728000004
>>> timeit(t5, number=10000)
2.642637542999182
>>> timeit(t6, number=10000)
0.2184546610005782

Вот, уже другой разговор, сразу видна разница, в среднем в 6 раз. Но погодите, почему последний тест t6() по скорости такой же как и t3()? Ведь строки теперь в 100 раз длиннее!
Это вопросы оптимизации кода, какие простые изменения ускоряют или замедляют выполнение программы. Мы столкнулись с примером обхода обращения к переменной. Например, именно так работает директива #define в С++, во время компиляции подставляя значение переменной вместо ссылки на неё.
В Python это тоже работает, но часто ли вы сможете встретить такой способ работы со строками? К сожалению, способ почти только теоретический.

В целом, тесты показали то, что мы хотели. Делаем выводы самостоятельно.

Полный листинг 🌍

#tricks
⭐️ Между прочим...
Читаете мой канал на телефоне? Код в примерах бывает растягивается в достаточно длинные строки. На мобиле смотреть не очень удобно. Особенно, учитывая, что переносы в Python это тоже синтаксис.
Но если повернуть телефон горизонтально, то всё становится куда приятней!

Казалось бы, это же очевидно! Но я сам не сразу догадался 😁
😉 Трик про flatten-список.
Задача: из списка списков сделать одноуровневый список

>>> arr = [[1, 2], [3, 4], [5, 6]]

Допустим, есть список интов

arr = [1, 2, 3, 4, 5]

Как получить их сумму? Очень просто!

>>> sum(arr)
15

И тут вы подумаете: Вау, какой удобный метод. Он просто берет список объектов и склеивает их через "+". Удобно же!
Такс, если list, как тип, поддерживает оператор сложения, то я же могу тогда сделать такой финт:

>>> arr = [[1, 2], [3, 4], [5, 6]]
>>> sum(arr)
Traceback (most recent call last):
File "<input>", line 2, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'list'

О нет! Счастье было так близко! 😭😢😩

Стоп, давайте разбираться. Если функция sum() просто прибавляет очередной аргумент списка к предыдущему, то с чем складывается самый первый элемент? Должно быть какое-то стартовое значение. И оно есть, это ноль "0". Потому-то мы и видим такую ошибку.
На наше счастье мы можем указать стартовое значение вместо ноля, чтобы получить сумму, используя в качестве начала другое число:

>>> arr = [1, 2, 3, 4, 5]
>>> sum(arr, 5)
20

И, следуя этой логике.....

>>> arr = [[1, 2], [3, 4], [5, 6]]
>>> sum(arr, [])
[1, 2, 3, 4, 5, 6]

YESSSS!!!! 😎🥰🤟

Вы, скорее всего, ломанётесь проверять другие типы и будете правы. С другими тоже работает. Но Python не упустит случай вас потроллить)))

>>> words = ['Hello ', 'world', '!']
>>> sum(words, '')
TypeError: sum() can't sum strings [use ''.join(seq) instead]

Не используйте "+" для склейки строк!

Скорее всего именно эта ошибка сделана на случай если вы захотите склеить большой текстовый документ, прочитанный через readlines().
_______
Это далеко не единственный способ сделать flatten-список. Пост скорей про функцию sum.

#tricks
Простая задача: получить рандомную строку.
Такое может потребоваться для создания секретного ключа, токена, одноразовой ссылки и тд. Как бы вы решали такую задачу?

Допустим, требуется строка в 20 символов.
Чаще всего решают примерно так:

from string import ascii_letters, digits
from random import choice
token = ''.join(random.choice(string.ascii_letters+string.digits) for _ in range(20))


Редко, но встречается и такой способ

import uuid
token = str(uuid.uuid4())[:20]

Но самый верный способ это готовый модуль secrets (Python3.6+) из стандартной библиотеки.

import secrets
token = secrets.token_urlsafe(15)
или
token = secrets.token_hex(10)

Почему лучше?

- Короче запись
- Более читаемый и понятный код
- Быстрей работает

Время на 1М запусков:

- random+string : ~14.3sec
- uuid : ~5.5sec
- модуль secrets : ~1.2sec

________
Прошу не путать получение рандомной строки и получение контрольной суммы.
Это разные по назначению задачи.

#tricks
Как соединить два списка? Список поддерживает оператор "+", так что это легко:

>>> l1 = [1, 2, 3]
>>> l2 = [4, 5, 6]
>>> l3 = l1 + l2
>>> print(l3)
[1, 2, 3, 4, 5, 6]

А как тоже самое повторить со словарями?

>>> d1 = dict(k1=1, k2=2)
>>> d2 = dict(k3=3, k4=4)
>>> d3 = d1+d2
TypeError: unsupported operand type(s) for +: 'dict' and 'dict'

Да, словари такой оператор не поддерживают. Самое распространённое решение это метод словаря update()

d1.update(d2)

Но это способ изменяет один из словарей, а нам нужно оставить оба словаря нетронутыми и создать третий.
Это можно сделать таким способом:

d3 = dict( d1.items() | d2.items() )

Или еще короче

d3 = {**d1, **d2}

Если количество словарей неизвестно и все они в одном списке, то вариантов еще больше.
Допустим, есть такой список со словорями:

dict_array = [
{'k1': 1, 'k2': 2},
{'k3': 3, 'k4': 4},
{'k5': 5, 'k6': 6},
]

Для объединения всех в один мегасловарь делаем так:

>>> from operator import or_
>>> from functools import reduce
>>> d3 = dict(reduce(or_, [x.items() for x in dict_array]))
{'k3': 3, 'k6': 6, 'k1': 1, 'k4': 4, 'k5': 5, 'k2': 2}

Если важна последовательность ключей, то еще есть способ

>>> from itertools import chain
>>> d3 = dict(chain.from_iterable(d.items() for d in dict_array))
{'k1': 1, 'k2': 2, 'k3': 3, 'k4': 4, 'k5': 5, 'k6': 6}

Или так

d3 = dict(chain(*map(dict.items, array)))

Есть вариант и покороче

>>> from collections import ChainMap
>>> d3 = dict(ChainMap(*array))

>>> print(d3)
{'k1': 1, 'k5': 5, 'k6': 6, 'k3': 3, 'k2': 2, 'k4': 4}

И даже еще короче, в одну строку и с сохранением порядка:

>>> d3 = dict(j for i in array for j in i.items())
>>> print(d3)
{'k': 1, 'k2': 2, 'k3': 3, 'k4': 4, 'k5': 5, 'k6': 6}

Можно ещё несколько вариантов придумать, но, думаю, достаточно 😁
__________
Все примеры создают новый словарь, не изменяя старые. Но если в исходных словарях есть другие словари или списки то для независимой копии нужно пройтись еще функцией copy.deepcopy() 😉

#tricks
Чтобы запустить два контекст менеджера одновременно можно написать так:

with open('script.py') as inp:
with open('_bkp.py', 'w') as out:
out.write(inp.read())

А можно и более компактно в одну команду:

with open('script.py') as inp, open('_bkp.py', 'w') as out:
out.write(inp.read())


#tricks
Стандартная функция enumerate() очень удобна для получения индекса итерации

>>> mylist = ['one', 'two', 'three']
>>> for i, item in enumerate(mylist):
>>> print(i, item)
0 one
1 two
2 three

Вот бы со словарями так! Чтобы удобно получить индекс, ключ и значение!
Это можно сделать так:

>>> mydict = {10: 'item1', 20: 'item2', 30: 'item3'}
>>> for i, key in enumerate(mydict):
>>> value = mydict[key]
>>> print(i, key, value)
0 10 item1
1 20 item2
2 30 item3

Третья строка явно лишняя, приходится дополнительно доставать значение по ключу. Как нам сократить код? Хочется распаковать ключ и значение сразу. Например так:

>>> for i, key, value in enumerate(mydict.items()):
>>> print(i, key, value)

И получаем ошибку

ValueError: not enough values to unpack (expected 3, got 2)

Справедливо, ведь enumerate() возвращает всегда кортеж из 2х элементов, а мы распаковывем его в 3 переменные.
Но есть одна хитрость, которая позволит сделать то что мы задумали! Скобочки!

>>> for i, (key, value) in enumerate(mydict.items()):
>>> print(i, key, value)
0 10 item1
1 20 item2
2 30 item3

Теперь норм 😎

#tricks
Работаете в PyCharm? Тогда этот пост для вас!

В Python3 добавлен синтаксис аннотаций. То есть, в объявлении функции можно указать какие типы данных у нас тут крутятся. От простых, до сложносоставных.
Например, есть такая функция:

def my_func(x: int, y: float) -> float:
val = x * y
return val

В этой функции явно указаны типы, а значит, что IDE сможет адекватно анализировать ваш код и использовать всевозможные вспомогательные штуки. Автокомплит, или различные предупреждения о неверном использовании типа.

Между тем, в Python2 также можно делать аннотации так, чтобы PyCharm их понял. Записываются они иначе, с помощью комментариев (type hint). Вот та же функция для Python2:

def my_func(x, y): # type: (int, float) -> float
val = x * y
return val

Интерпретатору не мешает, а для IDE подсказки😊

Но знаете ли вы, что такой способ можно использовать и не только для аннотирования функции? Можно указать тип любой переменной в любой строке!
Например, у вас есть внешний API в котором типы не объявлены вообще никак. А хочется иметь автокомплиты для возвращаемых значений.
Вот пример:

import some_api

def my_func() -> str:
value = some_api.get_value()
# ... life without autocomplete is pain(((
return value

Для переменной value IDE не сможет сообразить автокомплиты или проверку типов. Но с помощью такого же type hint мы можем ему помочь! Даже подсветка будет работать)

...
value = some_api.get_value() # type: str
# autocomplete for str here!!!
...

После этого у переменной value появится автокомплит и всё остальное.
Теперь вы можете обозначать типы переменных хоть на каждой строке 😎

Запоминаем формат:

[code]  # type: [Type]

#tricks
Перед вами простой словарик:

data = {1: 'value1', True: 'value2'}

С первого взгляда всё нормально. Давайте смотреть что у нас теперь есть в словаре

>>> data[1]
value2
>>> data[True]
value2

Кажется мы сломали питон) Но на самом деле нет. Это ошибка разработчика а не Python.

Дело в том, что на уровне данных для Python нет разницы между 1 и True.
Сам тип bool это производный клас от int

>>> issubclass(bool, int)
True

Значение True это частный случай int, равный 1. Поэтому у них одинаковый хеш, и словарь их воспринимает как один и тот же ключ

>>> hash(1)
1
>>> hash(True)
1

Так что же у нас сейчас в словаре?

>>> data 
{1: 'value2'}

Ключи добавляются в порядке их следования. И если такой ключ уже существует, то вместо создания нового ключа просто обновляется его значение. Поэтому у нас всего один ключ и это 1 а не True.

Чтобы избежать такой путаницы, возмите себе за правило ключи всегда делать одного типа.

C "0" и False всё аналогично.

#tricks
Как удалить из списка повторяющиеся элементы, сохранив порядок?

array = ['item1', 'item2', 'item3', 'item3', 'item1', 'item3', 'item2', 'item4']

Обычно используют преобразование в множество и обратно

unq = list(set(array))

Но такой способ ломает порядок элементов.
Правильный алгоритм выглядит так:

🔸Способ 0

unq = []
for item in array:
if item not in unq:
unq.append(item)

Теперь посмотрим как это записать короче

🔸 Способ 1
Создаем пустой список и в простом генераторе сначала проверяем а потом добавляем элемент если его еще нет в списке.

unq = []
[unq.append(item) for item in array if item not in unq]

🔸 Способ 2
Аналогичный, но с помощью set().

_set = set()
unq = [x for x in array if x not in _set and not _set.add(x)]

Здесь вторая проверка это хитрый "костыль". Функция на самом деле ничего не возвращает, просто нам надо её вызвать сразу после первой проверки, если она вернула True. Ответ функции add() инвертируем с помощью not чтобы оба условия сработали.

🔸 Способ 3
В одну строку как обычно с помощью set, но с последующей сортировкой для восстановления порядка.

unq = sorted(list(set(array)), key=array.index)

🔸 Способ 4
Здесь используем тот факт, что в словаре два одинаковых ключа быть не может и что ключи словаря теперь упорядочены (Python3+). Преобразуем элементы в ключи словаря и обратно в список.

unq = list(dict.fromkeys(array))
____________________
Способы 2-4 НЕ подходят, если элементы списка нехешируемые. То есть они не могут быть в качестве ключа словаря или элемента множества. Например, если у вас список словарей. В этом случае подходит только Способ 1.

#tricks
Часто требуется красиво распечатать ряд переменных через запятую. Возможно это требуется для дебага, а может является частью CLI.
Обычно решается через метод строки join()

>>>
args = ['val1', 'val2', 'val3']
>>>
print(", ".join(args))
val1, val2, val3

Если не все аргументы это строк то нужно их еще дополнительно преобразовать в строки.

>>> args = [1, 2, 3, 4]
>>>

print(", ".join([str(x) for x in args]))
или
>>>
print(", ".join(map(str, args)))
1, 2, 3, 4

Но самый простой способ это обычная функция print() (Python3)

>>> args = [1, 2, 3, 4]
print(*args, sep=", ")
1, 2, 3, 4

К сожалению такой способ не позволит легко сохранить получившуюся строку в переменную чтобы, например, использовать в логинге. Это возможно, но избыточно.

#tricks