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
В предыдущем посте мы запускали нужную нам функцию внутри генератора списка. Выглядит странно, когда мы не сохраняем результат этого генератора, так как обычно нужен именно он.
Обычно, в таких случаях более наглядно смотрится функция map():

array = [1, 2, 3, 4, 5, 6, 7, 8, 9]
more, less = [], []
map(lambda x: [less.append, more.append][x>5](x), array)

Проверяем результат

>>> print(more, less)
[] []

Хм, не сработало! Что произошло? Почему итерация не запустилась?
Дело в том, что в Python3 многие итеративные функции (если не все) стали генераторами. А что такое генератор? Это не тот генератор списка который List Comprehensions, а именно Generator.
Объект, который внутри себя имеет алгоритм получения следующего элемента и он не станет запускать итерацию пока мы не начнём запрашивать эти элементы.

Что возвращает функция map()?

>>> print(map(...))
<map object at 0x0000023114ADE3C8>

Тоже самое произошло и с функцией range

>>> print(range(10))
range(0, 10)

Значит всё что остаётся, это запустить итерацию по элементам генератора. Как это сделать максимально коротко?
Можно просто в цикле for

for _ in map(...): pass

Либо конвертнуть в список

list(map(...))

Или оператором *

[*map(...)]

Ну а полностью будет выглядеть так

[*map(lambda x: [less.append, more.append][x>5](x), array)]

Теперь генератор сразу вычисляется. Хотя, как по мне, такая запись менее "читабельна")

#tricks
В моих триках вы часто можете видеть способы сокращения кода. Когда длинный код заменяется на однострочный. Скорее всего, я продолжу эту практику, но хочу напомнить о важной вещи!

⚠️ Понятное лучше чем запутанное ⚠️

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

Если это можно сделать в Python, то это не значит что это нужно сделать.

Смотрим реальный пример из моей практики, когда я увлёкся и загнал все сравнения в один оператор if

>>> if any([fnmatch(rel, pat) for pat in include_files]) and not any([fnmatch(rel, pat) for pat in ignore_files]) and not any([any([fnmatch(name, pat) for pat in ignore_files]) for name in Path(rel).parts]):
>>> do_comething()

Да, это всего две строки))) Страшная мешанина!

Почему это плохо?
🔸 Это круто когда "заработало" но не круто когда через время смотришь на код и не поймешь что он делает.
🔸 Когда через время попробуешь что-то поправить, становится проще переписать заново (мой случай 😉)
🔸 Если работаете в команде, можно получить знатных [подставить страшное слово]лей. Такой код просто невозможно поддерживать.
Можно получить знатной брани на свою голову от себя же, пока не вспомнишь что это ты и писал)
🔸Если вы решились немного сгладить вину и расставить комментарии, то в однострочном выражении это не всегда получится. Символ игнора перехода на новую строку не позволит вам за ним писать комментарий.
Оба выражения ниже вызовут ошибку синтаксиса

x = 1 + \   # comment
2

x = 1 + \
# comment
2

Делайте код проще, избегайте сокращений, которые снижают читабельность кода.

⚠️ Простое лучше сложного ⚠️

А тем, кто еще не понял: import this
Помните пример с неудачной вставкой комментария?

x = 1 + \ # comment
2

x = 1 + \
# comment
2

Я говорил что так делать не стоит, так как вызывает ошибку. Но я же скажу как эту ошибку поправить! И это просто (((СКОБОЧКИ))) 😎!

x = (1 + # comment
2)

x = (1 +
# comment
2)

Теперь интерпретатор сообразит что к чему.
Кстати, они же помогают избежать символа "\" в других случаях

Без скобок

from Qt.QtWidgets import QPushButton, QLabel, \
QTextEdit, QListWidget

Со скобками

from Qt.QtWidgets import (QPushButton, QLabel,
# здесь можно вставить комментарий
QTextEdit, QListWidget)

Без скобок

if a > 0 \
and b > 0 \
and c < 10:
pass

Со скобками

if (a > 0
# комент
and b > 0
# комент
and c < 10):
pass

Но прошу заметить, как порой страшно выглядят данные конструкции. Применяйте осторожно!
Да и лишние скобки в Python смотрятся не всегда уместно)

#tricks
Стандартный модуль json имеет command line интерфейс. Он умеет делать валидацию JSON-данных и переводить однострочный вариант в форматированный.

Изменяем форматирование

$ echo '{"key1": "value1", "key2": "value2"}' | python3 -m json.tool
{
"key1": "value1",
"key2": "value2"
}

Заменяем прямой ввод данных на данные из файлов

python3 -m json.tool < single.json > pretty.json

Вместо stdin и stdout просто передаём путь к файлам, результат тот же.

python3 -m json.tool single.json pretty.json

Как происходит валидация? Да просто команда завершится с ошибкой если формат данных неверный (exit code 1). В stderr распечатается информация о том где произошёл сбой.

#libs
Регулярные выражения иногда могут быть просто монструозными. Выглядеть это может крайне запутанно. Сами регэкспы и без того история непростая, а когда это длинный паттерн на несколько десятков знаков, разобрать там что-либо становится не просто.

Но на помощь приходит Python и его стремление сделать нашу жизнь проще!

В функциях регулярок можно после паттерна указывать флаги, один из которых позволяет писать паттерны более свободно. А именно, добавлять пробелы и переносы, которые будут игнорированы. В результате мы можем разбить паттерн на строки и добавить комментов.

Чтобы это сработало нужно добавить флаг re.VERBOSE. Пробелы в паттерне теперь следует указывать явно спец символами.
Согласитесь, что даже с именованными группами а таком виде регэкспа выглядит вполне сносно 😉.

#tricks #regex
20 апреля 2020 года вышел последний релиз Python версии 2. Им стал билд 2.7.18. На этом точно всё, официально версия закрыта.
Развитие будет только за счёт сторонних разработчиков в контексте их проектов, где 2й Python никак не обойти.
Хотите оформить свои CLI скрипты красивым прогрессбаром?
Стоит посмотреть на библиотеку progress.
Она даёт возможность создавать красивые прогрессбары легко и быстро.

Но что если не хочется добавлять лишнюю зависимость только ради одной бегущей полоски?
Создать свою функцию с подобным функционалом — не проблема!

Вот базовый набросок:

import time
for i in range(0, 100+1, 4):
time.sleep(0.1)
print('\r{:>3}% [{}{}]'.format(i, "#"*i, '-'*(100-i)), end='')
print()

Запустите код в интерактиве и увидите что работает не хуже.
Вся хитрость в отсутствии переноса на новую строку в конце строки и в символе "\r" — возврат "каретки" (читай "курсора") в начало строки. После чего мы перезаписываем предыдущую строку.
Главное в самом конце не забыть обычный print() чтобы перейти на новую строку.

Расширенный вариант в виде контекст менеджера. 🌎

#libs #tricks
Ранее я делал серию постов про битовые операторы.
Вот вам ещё один наглядный пример как это используется в Python в модуле re.

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

pattern = re.compile(r"(\w+)+")
words = pattern.search(text, re.DOTALL)

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

pattern.search(text, re.DOTALL | re.VERBOSE)

А теперь смотрим исходники, что находится в этих атрибутах?
Не удивительно, степени двойки. Почему? Потому что каждое следующее значение это сдвиг единицы влево.

>>> for n in [1, 2, 4, 8, 16, 32, 64, 128, 256]:
>>>
print(bin(n))

0b1
0b10
0b100
0b1000
0b10000
0b100000
0b1000000
0b10000000
0b100000000

Чтобы было понятней, давайте напишем тоже самое но иначе, добавим ведущие нули:

000000001
000000010
000000100
000001000
000010000
000100000
001000000
010000000
100000000

Не понятно что тут происходит? Читай три поста про битовые операторы начиная с этого ➡️ https://t.iss.one/pythonotes/45

В общем, это пример применения побитовых операций в самом Python.
Теперь вы знаете Python еще немного лучше)

#tricks #regex #libs
Каждый модуль в Python имеет атрибут ˍˍnameˍˍ. В него записывается строка, содержащая полное имя модуля. Например:

>>> from my_package import my_module
>>> print(my_module.__name__)
'my_package.my_module'

Очень удобно для логгинга когда в имя логгера требуется записать имя модуля, в котором происходят события лога.
Но однажды мне потребовалось создать логгер не для модуля в целом, а для отдельной функции. Что в этом случае можно сделать?
Такой объект как функция тоже имеет атрибут ˍˍnameˍˍ

>>> class MyClass:
>>> def func(self):
>>> pass
>>> print(MyClass.func.__name__)
'func'

Итого нам следует собрать полное имя таким образом:

>>> full_name = '.'.join([__name__, MyClass.__name__, MyClass.func.__name__])
>>> print(full_name)
'my_package.my_module.MyClass.func'

А что если класс вложен в другой класс?

>>> class MyClass:
>>> class SubClass:
>>> def func(self):
>>> pass
>>> full_name = '.'.join([__name__, MyClass.__name__, MyClass.SubClass.__name__, MyClass.SubClass.func.__name__])
>>> print(full_name)
'my_package.my_module.MyClass.SubClass.func'

А что если вложений несколько?

>>> class MyClass:
>>> class SubClass1:
>>> class SubClass2:
>>> def func(self):
>>> pass

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

>>> from somewhere.something import OtherClass

>>> class MyClass2(OtherClass):
>>> pass

Cтрашно подумать как нам достать настоящее "полное имя" функции! А если там динамическое определение наследования или метаклассы? Вообще можно забить на решение и придумать другой способ 😵

На помощь приходит PEP3155 и его имплементация "Qualified name".

Всё очень просто, начиная с Python 3.3 классы и функции имеют атрибут ˍˍqualnameˍˍ, который и содержит полное или "честное" или "квалифицированное" имя объекта. Именно в него уже записан готовый полный адрес объекта. И теперь даже такая конструкция сработает правильно:

>>> # some_module.py
>>>
>>> class OtherClass:
>>> class SubClass1:
>>> class SubClass2:
>>> def func(self):
>>> pass
>>>
>>> # my_module.py
>>> from some_module import OtherClass
>>> class FinalClass(OtherClass.SubClass1.SubClass2):
>>> pass
>>>
>>> full_name = '.'.join([__name__, FinalClass.func.__qualname__])
'my_package.my_module.OtherClass.SubClass1.SubClass2.func'

Тут стоит заметить, что если функция находится не в текущем модуле а откуда-то импортирована (как в моём примере) то собирать имя с локальным атрибутом ˍˍnameˍˍ уже не имеет особого смысла. Тогда лучше использовать имя модуля исходного класса:

>>> full_name = '.'.join([FinalClass.__module__, FinalClass.func.__qualname__])
'some_module.OtherClass.SubClass1.SubClass2.func'

#pep
В прошлом посте мы получали полное имя метода. В атрибут ˍˍqualnameˍˍ записано имя только до класса, к которому метод принадлежит. Как быть если нужно полное имя с модулем, а мы имеем только объект метода без инстанса или ссылки на класс?

func = get_my_method()

В результате этого псведо-выражения мы имеем только сам метод класса. Есть ли в нём информация к какому классу он принадлежит? Конечно есть, это атрибут ˍˍselfˍˍ. Он ссылается на инстанс. А оттуда, как не трудно догадаться, можно получить и ссылку на класс через атрибут ˍˍclassˍˍ:


cls = func.__self__.__class__

итого полное имя будет выглядеть так

full_name = '.'.join([
func.__self__.__class__.__module__,
func.__qualname__]
)

#tricks
Наверняка вы часто используете генераторы списков (List Comprehension). Я тоже, даже порой злоупотребляю ими) Но что поделать, если они такие удобные!

Давайте разберёмся как работают составные итерации в генераторе списка.
Для начала посмотрим простой вид.

>>> array = [1, 2, 3, 4]
>>> [x for x in array]

Этот код просто делает копию списка. Добавляем некоторое выражение:

>>> [x*2 for x in array]

Этот генератор создаёт новый список элементы которого в 2 раза больше чем в оригинальном списке. Добавим условие:

>>> [x*2 for x in array if x%2]

Предыдущему примеру добавили фильтр, который проходят только нечётные числа.
В целом, я бы советовал на этом и остановиться. Не нужно усложнять простое!

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

>>> array1 = [1, 2, 3]
>>> array2 = [4, 5, 6]
>>> print([f'{i}-{j}' for i in array1 for j in array2])
['1-4', '1-5', '1-6', '2-4', '2-5', '2-6', '3-4', '3-5', '3-6']

Давайте обозначу каждый цикл условными скобками чтобы было понятней (синтаксически это неверно).

[f'{i}-{j}' (for i in array1) (for j in array2)]

Можем добавить условие и сюда? Можем!

>>> print([f'{i}-{j}' (for i in array1 if i >= 2) (for j in array2 if j < 5)])
['2-4', '3-4']

При этом во второй итерации мы можем использовать переменную из первой

>>> array = [(1, 2, 3), (4, 5, 6)]
>>> new = [y for x in array for y in x]

Кстати, мы разложили вложенные кортежи в один плоский список.

Можем ли мы использовать больше двух итераций? Да хоть десять!

>>> a1 = [9, 2, 4, 5]
>>> a2 = [4, 5, 9, 1]
>>> a3 = [5, 9, 3, 0]
>>> match = [x for x in a1 for y in a2 for z in a3 if x == y and x == z]
[9, 5]

Нашли числа которые встречаются во всех списках.

Можно еще сложней? Конечно, Python довольно многое позволяет делать.
Но, как говорят про Python: если ЭТО можно сделать то еще не значит что ЭТО нужно делать.

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

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

ls = [1, 2, 3, 4, 6]

И поехали!

>>> ls[-1] = 5
[1, 2, 3, 4, 5]

Обычное изменение по индексу.

>>> ls[2:4]
[3, 4]

Обычный срез который создаёт новый список на основе старого.

>>> ls[::-1]
[5, 4, 3, 2, 1]

Реверс списка, тоже создаёт новый. Оригинальный список остался без изменений.
Воспользуемся оператором присвоения:

>>> ls[2:4] = [7, 8]
[1, 2, 7, 8, 5]

Заменили диапазон элементов в оригинальном списке.

>>> ls[4:] = [0, 0, 0, 0]
[1, 2, 7, 8, 0, 0, 0, 0]

Указав диапазон сверх имеющегося мы расширили список по аналогии с методом extend(), но при этом еще и немного захватили конец списка. Всё это в одно действие!

>>> ls[:0] = [9, 8, 7]
[9, 8, 7, 1, 2, 7, 8, 0, 0, 0, 0]

Добавили элементы в начало

>>> del ls[-4:]
[9, 8, 7, 1, 2, 7, 8]

Удалили часть элементов списка

>>> ls[1:3] = []
[9, 1, 2, 7, 8]

Еще один способ удалить элементы

>>> ls[:] = []

А этим способом можно пользоваться для очистки списка в Python2, в котором еще не было метода clear().

Кажется мы "потратили" весь наш список)))
Сделаем новый и продолжим.

>>> ls = [1, 2, 3, 4, 5, 6]
>>> ls[::2] = [7,8,9]
[7, 2, 8, 4, 9, 6]

И конечно же мы можем использовать шаг в срезе, но тут требуется соблюдать количество подаваемых элементов. Заменять элементы с каким-то шагом можно, а добавлять нельзя.

s = slice(3, 4)

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

>>> ls[s] = [0]*5
[7, 2, 8, 0, 0, 0, 0, 0, 9, 6]

Заменили один элемент на несколько элементов, расширив исходный список из центра.

И напоследок. Следующие два действия равнозначны и дадут одинаковый результат — полная замена списка.

>>> ls[:] = [1,2,3,4]
>>> ls = [1,2,3,4]

#tricks
В Python всё является объектами.
Это значит что у каждой сущности есть тип и какие-либо методы.
Мы знаем что есть методы у строк

>>> 'string'.upper()

у списков

>>> [1,2,3].count(2)

у словарей

>>> {"key": 123}.items()

А есть ли какие-то методы у простых чисел? Не много, но есть!

Например, возьмём простой int

>>> a = 22

Метод bit_length() покажет сколько потребуется бит для отображения данного числа в двоичном представлении, исключая ведущие нули.

>>> a.bit_length()
5

Проверяем

>>> bin(a).lstrip('-0b')
'10110'

Всё верно.

Проверим float

>>> b = 10.5

Мы можем проверить есть ли у числа дробная часть

>>> b.is_integer()
False

Получить наш float в виде простой десятичной дроби

>>> b.as_integer_ratio()
(21, 2)

Конечно же Python не имеет типа "десятичная дробь", поэтому мы просто получаем кортеж из двух элементов: числитель и знаменатель.

У int тоже есть такой метод (Python3.8+), но он работает "хитро". Целое число всегда равно дроби где в числителе это же число а в знаменателе 1. Поэтому данный метод у int всегда возвращает (x, 1). 😕

Кстати, чтобы обойтись без переменной просто возьмите число в скобки

>>> (10.0).is_integer()
True

#tricks
У строки в Python есть два очень похожих метода. На столько похожих что кажется они делают одно и тоже.
Это метод isdigit() и isnumeric()

Давайте посмотрим зачем нам два одинаковых метода? И так ли они одинаковы?

Очевидно что isdigit() говорит нам, состоит ли строка только из чисел 0-9

>>> '12'.isdigit()
True
>>> '12x'.isdigit()
False
>>> '-12'.isdigit()
False
>>> '12.5'.isdigit()
False

Можно предположить что isnumeric() делает более глубокий анализ и распознаёт в строке float или отрицательное число.

>>> '15'.isnumeric()
True
>>> '-15'.isnumeric()
False
>>> '15.2'.isnumeric()
False

Нет, всё так же как и с другим методом. В чем же тогда разница? Для начала посмотрим следующие примеры:

>>> '5'.isdigit(), '5'.isnumeric() # Обычная цифра 5
# True, True
>>> '꧕'.isdigit(), '꧕'.isnumeric() # Яванская 5
# True, True
>>> '෩'.isdigit(), '෩'.isnumeric() # Синхала 3
# True, True
>>> '৩'.isdigit(), '৩'.isnumeric() # Бенгальская 3
# True, True
>>> '༣'.isdigit(), '༣'.isnumeric() # Тибетская 3
# True, True
>>> '³'.isdigit(), '³'.isnumeric() # 3 верхний индекс (степень)
# True, True
>>> '𝟝'.isdigit(), '𝟝'.isnumeric() # Математическая двойная 5
# True, True
>>> '๔'.isdigit(), '๔'.isnumeric() # Тайская 4
# True, True
>>> '➑'.isdigit(), '➑'.isnumeric() # 8 в круге
# True, True

А теперь примеры в которых, по мнению Python, результаты не равны

>>> '¾'.isdigit(), '¾'.isnumeric() # дробь три четверти
# False, True
>>> '⅕'.isdigit(), '⅕'.isnumeric() # дробь одна пятая
# False, True
>>> '𒐶'.isdigit(), '𒐶'.isnumeric() # клинопись 3
# False, True
>>> '三'.isdigit(), '三'.isnumeric() # 3 из унифицированной идеограммы
# False, True
>>> '⑩'.isdigit(), '⑩'.isnumeric() # цифра 10 в круге
# False, True
>>> 'Ⅳ'.isdigit(), 'Ⅳ'.isnumeric() # Римская 4
# False, True
>>> '𑇪'.isdigit(), '𑇪'.isnumeric() # Сенегальская архаическая 10
# False, True
>>> '𐌢'.isdigit(), '𐌢'.isnumeric() # Этрусская цифра 10
# False, True
>>> 'ↂ'.isdigit(), 'ↂ'.isnumeric() # Римская цифра 10000
# False, True
>>> '〇'.isdigit(), '〇'.isnumeric() # Символ ККЯ ноль
# False, True

Получается, что isdigit() говорит нам, является ли символ десятичной цифрой или спецсимволом, имеющим цифирное значение после преобразования.
В свою очередь isnumeric() включает все дополнительные символы юникода которые имеют отношения к числовым и цифровым представлениям.

Ну и пара примеров в которых в обоих случаях символ не является числом, это эмодзи.

>>> '🕙'.isdigit(), '🕙'.isnumeric() # эмодзи 10 часов
# False, False
>>> '7️⃣'.isdigit(), '7️⃣'.isnumeric() # эмодзи 7
# False, False

Также есть еще один дополнительный и весьма полезный метод isdecimal().
Он нам сообщает, можно ли из указанного символа сделать простую десятичную цифру. То есть сработает ли метод int(x)

>>> '෩'.isdecimal(), int('෩') # Синхала 3
# True, 3
>>> '➑'.isdecimal(), int('➑') # 8 в круге
# False, ValueError

Какие выводы?

🔸 При определении цифры в строке isdigit() подходит лучше чем isnumeric(), но оба не гарантируют успешную конвертацию в int
🔸 Для однозначного определения возможности преобразования строки в int лучше подходит метод isdecimal()
🔸 Для однозначного определения символов 0...9 лучше использовать regex

Полный список символов юникода которые определяются как numeric

#basic
В продолжение прошлого поста про цифры в мире строк.
Почему методы isdigit() и isnumeric() не определяют в строке float и отрицательные значения?
Дело в том, что эти методы работают с ЦИФРАМИ, то есть с единичным символом. А строка "-2" или "3.4" это уже ЧИСЛО. То есть не символ а значение, записанное несколькими символами.

Все озвученные методы проходятся по каждому символу строки и проверяют их индивидуально.

В юникоде есть символы цифр с точками "🄀⒈⒉⒊⒋⒌⒍⒎⒏⒐"
Каждая из них это ОДИН СИМВОЛ, поэтому он будет считаться цифрой

>>> '⒌'.isdigit(), '⒌'.isnumeric()
True, True

Но когда мы пишем это выражение в два символа ( 5+точка), то это не работает.

>>> '5.'.isdigit(), '5.'.isnumeric()
False, False

А еще есть такие символы

>>> '⑴⑵⑶⑷⑸'.isdigit()
True
>>> '🄁🄂🄃'.isdigit()
True

Но они не преобразуются в десятичные цифры

>>> '⒈'.isdecimal()
False
>>> '🄃'.isdecimal()
False
>>> '⑶'.isdecimal()
False


#basic
"Ну и как же нам перекидывать строки ви числа?" спросите вы. Проверять каждый символ, очистив строку от лишних знаков и точек. Потом конвертить допустимые символы в числа и восстанавливать знак, дробную чусть и тд???
Самый быстрый способ это просто "попробовать" 😜

text = "-0.3"
try:
num = float(text)
except ValueError:
print('Dough!')

Всё остальное это уже парсинг и разбор символов для иных целей.

#basic
Допустим, имеется у нас задача: разделить одно число на другое и получить отдельно целое число и остаток от деления.

Например, исходные числа 15 и 2. В результате должны получить 7 и 1. То есть 7 раз двойка входит в состав 15 целиком и потом остаётся еще 1.

Как будем действовать?
Очевидно же, целое число вхождений получаем через floor division

>>> 15//2
7

Остаток через деление по модулю

>>> 15%2
1

Но можно сделать проще (ох, ну куда уж проще то 😄). В Python есть builtin функция divmod которая делает эти два действия в одно.

>>> divmod(15, 2)
(7, 1)

#tricks
Python 3.9 готовит нам приятный сюрприз в PEP584.
Ранее мы обсуждали как можно удобно сложить вместе два словаря получив новый словарь. Было много вариатов, но ни одного идеального. Наконец-то в Python добавили оператор для слияния словарей!!! Это оператор "|".
А это значит, что начиная с 3.9 соединить словари можно таким синтаксисом:

dct3 = dct1 | dct2

Чтобы обновить словарь, можно использовать такой синтаксис

dct1 |= dct2

Данный функционал уже можно опробовать, установив первые релизы.

#pep
В Python есть стандартный модуль sched для синхронного планировщика задач. Что??? Синхронных??? В наш-то век "асинхронщины" и "параллельщины"!
Спокойно, сначала смотрим код, потом разбираемся.

Работает это так:

- создаём планировщик
- добавляем задачи в очередь с таймаутом и приоритетом
- запускаем и ждём пока завершится вся очередь

Смотрим пример:

import sched, time

def func(name):
t = round(time.time()-start_time, 2)
print(f"Execute {name} ({t}s)")

# просто отметка времени старта
start_time = time.time()
# создаём планировщик
s = sched.scheduler(
time.time, # функция замера времени
time.sleep # функция ожидания
)
# добавляем задачи
s.enter(0, 1, func, argument=('ev1',))
s.enter(2, 1, func, argument=('ev2',))
s.enter(1, 1, func, argument=('ev3',))
# запускаем очередь на исполнение
s.run()

Описание аргументов

Вывод получаем в соответствии со временем задержки:

Execute ev1 (0.0s)
Execute ev3 (1.0s)
Execute ev2 (2.0s)

Функция run() запускает планировщик и начинается выполнение задач в порядке очереди по времени и приоритету.
Время указывается с момента старта очереди. Если время совпадает то сортировка идёт по приоритету.

Что значит синхронный планировщик? Это значит что задачи будут выполняться строго по очереди в одном потоке. Никаких мультипотоков и мультипроцессов, модуль прост как бревно!

Если у первой задачи стоит задержка 0сек а у второй 1сек, и при этом первая задача выполняется 3сек, то вторая задача выполнится только через 3 сек. Никакого параллельного запуска не будет.

Параметр delay следует понимать не как "запусти через N сек" а как "запусти не раньше чем через N сек". Время запуска следующей зависит от выполнения предыдущих задач.

Где это может пригодиться? Очередь задач, между которыми должен быть промежуток времени по какой-либо причине.

🔸 несколько синхронных задач, которые должны выполняться друг за другом но требующие ожидания обновления какой-либо инфраструктуры (тормозная сеть?)
🔸 сетевой API который имеет лимит на количество команд в единицу времени
🔸 фейковая задержка для генерации тестов или имитация поведения юзера.

Конечно, всё это можно решить банальным time.sleep() в нужном месте, но sched даёт несколько более удобный интерфейс управления задачами.

#libs
В прошлом посте мы рассмотрели синхронный планировщик задач sched. Функционал вроде не плох, но синхронное выполнение с блокировкой всё портит☹️. Можем ли мы как-то поправить ситуацию?

На самом деле можем (помимо отправки всего в subprocess).

Функция run() принимает аргумент blocking, который по умолчанию True.
То есть, если мы укажем blocking=False то получим неблокирующее выполнение? Нет. Этот параметр работает иначе.

Если мы делаем неблокирующий запуск, то после вызова метода run(blocking=False) планировщик выполнит все задачи, которым пришло время исполниться в обычном блокирующем синхронном режиме и вернёт время, через которое следует запуститься следующей задаче. То есть через какое время нужно запустить run(...) еще раз.
Получается, что вместо ожидания таймаута без полезной нагрузки планировщик освобождает поток и сообщает через сколько ему пора будет продолжить работу. А что делать с этой информацией, решаете сами.

Выполнение следующих задач произойдет после следующего вызова run() и если пришло их время выполниться.

Например, вместо ожидания следующей задачи будем делать что-то полезное:

import sched, time

def func(name):
# задача
t = round(time.time()-start_time, 2)
print(f"Execute {name} ({t}s)")

start_time = time.time()
# создаём планировщик
s = sched.scheduler(time.time, time.sleep)
s.enter(0, 1, func, argument=('ev1',))
s.enter(1, 1, func, argument=('ev2',))
s.enter(2, 1, func, argument=('ev3',))

delay = 0
last_run_time = time.time()
while True:
# выполнение заданий планировщика
if last_run_time + delay < time.time():
delay = s.run(blocking=False)
if delay is None:
break
last_run_time = time.time()

# здесь делаем что-то полезное
print('Делаем что-то полезное...')
time.sleep(0.1)

print('Complete all tasks')

Планировщик остаётся по-прежнему синхронным, но теперь вместо бесполезного ожидания мы можем запустить другой код на исполнение и знаем когда следует вернуться к планировщику.
___________________
PPS. Да, в этих примерах я нагло использую глобальные переменные))) Не делайте так на реальных проектах