Метка: pyway

Парсим Википедию с Beautiful Soup 4

Для одного из моих проектов понадобилось раздобыть список субъектов РФ с их гербами. Я решил автоматизировать этот процесс, написав скрипт на языке Python. Поделюсь с вами процессом разработки, трудностями, с которыми столкнулся и их решениями.

Парсить будем эту страницу – статья «Субъекты Российской Федерации»: https://ru.wikipedia.org/wiki/%D0%A1%D1%83%D0%B1%D1%8A%D0%B5%D0%BA%D1%82%D1%8B_%D0%A0%D0%BE%D1%81%D1%81%D0%B8%D0%B9%D1%81%D0%BA%D0%BE%D0%B9_%D0%A4%D0%B5%D0%B4%D0%B5%D1%80%D0%B0%D1%86%D0%B8%D0%B8

Нам понадобятся библиотеки (requests – для HTTP запросов, BeautifulSoup4 – для разбор HTML документа и lxml – нужна для BeautifulSoup4):

pip install requests lxml BeautifulSoup4

BeautifulSoup4 сам по себе не ставит парсер lxml, его мы поставили вручную.

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

import requests
from bs4 import BeautifulSoup
import os

URL = 'https://ru.wikipedia.org/wiki/%D0%A1%D1%83%D0%B1%D1%8A%D0%B5%D0%BA%D1%82%D1%8B_%D0%A0%D0%BE%D1%81%D1%81%D0%B8%D0%B9%D1%81%D0%BA%D0%BE%D0%B9_%D0%A4%D0%B5%D0%B4%D0%B5%D1%80%D0%B0%D1%86%D0%B8%D0%B8'
FILE = 'wikipage.html'


def load_url_contents_cached(url, local_file):
    # если есть на диске, то считаем с диска
    if os.path.exists(local_file):
        with open(local_file, 'r') as f:
            contents = f.read()
    else:
        # нет на диске - скачаем
        contents = requests.get(url).text
        # сохраним в файл
        with open(local_file, 'w') as f:
            f.write(contents)
    return contents

В переменной contents лежит целиком HTML код в виде текста. Превратим его в суп, т. е. в объект BeautifulSoup, который даст возможность работать с ним в форме объектной модели: искать элементы по их именам, классам и атрибутам, и извлекать их контент и свойства.

soup = BeautifulSoup(contents, 'lxml')

print(soup.prettify())  # посмотреть код страницы – много текста

Далее, нам нужно понять, что искать на странице. Мне нужна таблица с регионами и скачать их гербы. Листаем до таблицы, выбираем название первого субъекта, правая кнопка, посмотреть код. Да, сразу скажу, что использую браузер Chrome.

Ищем элемент.

Находим в открывшейся консоли разработчика нужный элемент с названием региона, снова правой кнопкой – Copy – Copy selector. Это копирует селектор (строку, которая позволяет однозначно выбрать именно этот элемент страницы) в буфер обмена.

Копирование селектора

Теперь по селектору BS4 может найти нам элемент:

elem = soup.select('#mw-content-text > div > table.standard.sortable.jquery-tablesorter > tbody > tr:nth-child(3) > td:nth-child(2) > a')

print(elem)  # []

Увы, пусто. Давайте откроем код страницы, чтобы посмотреть, что не так. Оказывается при загрузке у таблицы нет класса jquery-tablesorter. Он добавляется уже во время исполнения кода JS:

Классы таблицы.

А мы парсим просто HTML пришедший с сервера и не запускаем никакие скрипты на страницы. Поэтому удаляем этот лишний класс из селектора:

elem = soup.select('#mw-content-text > div > table.standard.sortable > tbody > tr:nth-child(3) > td:nth-child(2) > a')

print(elem)
# [<a href="/wiki/%D0%90%D0%B4%D1%8B%D0%B3%D0%B5%D1%8F" title="Адыгея">Республика Адыгея</a>]

Теперь элемент успешно обнаруживается. Но мы разобьем этот запрос на части, чтобы сначала извлечь все строки таблицы, а затем из каждой строки достать название и ссылку. Изучим, как нам достать путь к картинге герба из каждой строки таблицы tr:

Путь к картинке герба

Можно было бы взять атрибут src от img, но в атрибуте srcset есть варианты побольше размером, однако его придется тоже разобрать на части – разбить сначала по запятым, потом по пробелам.

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

def extract_names_and_image_urls(contents):
    soup = BeautifulSoup(contents, 'lxml')

    # строки таблицы
    rows = soup.select('#mw-content-text > div > table.standard.sortable > tbody > tr')

    for row in rows:
        # все ячейки этой строки
        columns = row.select('td')
        try:
            # название региона - это содержание нулевого по счету тэга "a" в ячейке номер 1
            name = columns[1].select('a')[0].text.strip()

            # а картинка в 3 ячейке в тэге img возьмем атрибут srcset
            image_page_url: str = columns[3].select('img')[0]['srcset']
            
            # разбить атрибут по запятым, взять последний вариант, потом забить по пробелам, взять адрес
            # и добавить протокол к нему, чтобы иметь полный URL
            large_image = 'https:' + image_page_url.split(',')[-1].strip().split(' ')[0]

            yield name, large_image

        except IndexError:
            continue

Если не понимаете, что именно происходит, то запустите этот код и увидите по шагам, как мы достаем данные из тэгов:

def separator(double=False):
    """Разделительная линия"""
    print('-' * 100)
    if double:
        print()
        separator()
        

contents = load_url_contents_cached(URL, FILE)
soup = BeautifulSoup(contents, 'lxml')

# строки таблицы
rows = soup.select('#mw-content-text > div > table.standard.sortable > tbody > tr')

# 42 строка
row = rows[42]

print(row)

separator()

columns = row.select('td')
print(columns)

separator()

print(columns[1])

separator()

print(columns[1].select('a'))

separator()

print(columns[1].select('a')[0])

separator()

print(columns[1].select('a')[0].text)

separator(double=True)

print(columns[3])

separator()

print(columns[3].select('img'))

separator()

print(columns[3].select('img')[0])

separator()

print(columns[3].select('img')[0]['srcset'])

separator()

image_page_url = columns[3].select('img')[0]['srcset']

print(image_page_url.split(','))

separator()

print(image_page_url.split(',')[-1])

separator()

print(image_page_url.split(',')[-1].strip().split(' '))

separator()

print(image_page_url.split(',')[-1].strip().split(' ')[0])

separator()

print('https:' + image_page_url.split(',')[-1].strip().split(' ')[0])

Полученные картинки нужно только скачать:

def download_file(url, save_to):
    r = requests.get(url, allow_redirects=True)
    with open(save_to, 'wb') as f:
        f.write(r.content)

Собираем все вместе. Качаем страницу, читаем список имен и адресов, создаем папку для картинок, качаем каждый герб и сохраняем его под именем субъекта:

data = extract_names_and_image_urls(load_url_contents_cached(URL, FILE))

IMAGE_PATH = 'out_img'
os.makedirs(IMAGE_PATH, exist_ok=True)  # создать папку, если ее нет 

for name, image_url in data:
    print(f'Downloading {name}')
    download_file(image_url, os.path.join(IMAGE_PATH, name + '.png'))

Вот что у меня сохранилось в итоге:

Результаты загрузки

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Пишем Brainfuck на Python

Есть мнение, что каждый программист должен написать в жизни хотя бы один язык программирования, либо реализовать уже существующий, но своими руками. В качестве примера мы начнем с самого простого и создадим Brainfuck. Согласно Вики:

Brainfuck — один из известнейших эзотерических языков программирования, придуман Урбаном Мюллером (нем.Urban Müller) в 1993 году, известен своим минимализмом. Название языка можно перевести на русский как вынос мозга, оно напрямую образовано от английского выражения brainfuck (brain — мозг, fuck — иметь половое сношение), т. е. заниматься ерундой. Язык имеет восемь команд, каждая из которых записывается одним символом. Исходный код программы на Brainfuck представляет собой последовательность этих символов без какого-либо дополнительного синтаксиса.

Википедия.

Описание 8 команд:

Команда BrainfuckОписание команды
Начало программывыделяется память под 30 000 ячеек с нулевыми начальными значениями
>перейти к следующей ячейке
<перейти к предыдущей ячейке
+увеличить значение в текущей ячейке на 1
-уменьшить значение в текущей ячейке на 1
.напечатать значение из текущей ячейки
,ввести извне значение и сохранить в текущей ячейке
[если значение текущей ячейки ноль, перейти вперёд по тексту программы на ячейку, следующую за соответствующей ] (с учётом вложенности)
]если значение текущей ячейки не нуль, перейти назад по тексту программы на символ [ (с учётом вложенности)

Реализация интерпретатора на Python

Интерпретатор читает программу и выполняет ее сразу на лету.

Первым делом надо прочитать целиком файл с исходником на BF, имя которого мы передаем первым параметром командной строки:

if __name__ == '__main__':
    if len(sys.argv) == 2:
        # один аргумент - имя файла, который надо выполнить
        with open(sys.argv[1], 'r') as f:
            code = f.read()
        run(code)  # запустить код
    else:
        print(f'usage: python {sys.argv[0]} file.bf')

После того, как код прочитан, мы создаем память из 30 000 ячеек, заполненными нулями. Устанавливаем счетчик команд и указатель на ленте ячеек на 0. Инициализация имеет такой вид:

mem_size = 30000
memory = [0] * mem_size  # оперативная память программы
data_ptr = 0  # индекс текущей ячейки памяти (memory)
instr_ptr = 0  # индекс текущей инструкции кода (code)

Далее в цикле читаем ячейку, если это одна из наших 8 команд, то выполняем, иначе пропускаем (это могут быть пробелы и переносы строк, их просто игнорируем). После выполнения команды, указатель сдвигается вправо по коду (увеличивается на единицу). Программа выполняется до тех пор, пока счетчик инструкций не выйдет за пределы размера кода.

С командами типа «+», «-«, «<«, «>» проблем в понимании быть не должно, они делают простые действия. Разве что, вам следует обратить внимание на то, что я зацикливаю оперативную память, если команда смещения переходит границу (команда < от 0 сдвинет на 29999 ячейку, а команда > с ячейки номер 29999 сдвигает на 0 ячейку).

Фрагмент цикла:

# выполняем - пока не вылетели за пределы кода
while instr_ptr < len(code):
    command = code[instr_ptr]  # текущая команда

    if command == '+':
        memory[data_ptr] += 1
    elif command == '-':
        memory[data_ptr] -= 1
    elif command == '>':
        data_ptr = (data_ptr + 1) % mem_size  # циклическая память
    elif command == '<':
        data_ptr = (data_ptr - 1) % mem_size  # циклическая память
    ...
    instr_ptr += 1   # сдвигаеи к следующей команде

Команда точка «.» печатает на экране единственный символ из текущий ячейки памяти. Я использую функцию chr, чтобы из числа получить символ. end='' указывает, что не надо переводить строку. Команда запятая «,» вводит символ из потока ввода. Считываю строку, беру первый символ и получаю его код функцией ord. Дополним код цикла обработкой ввода и вывода:

...
elif command == '.':
    # печатаем символ с кодом = значения текущей ячейки
    print(chr(memory[data_ptr]), end='')
elif command == ',':
    # ввод - берем код первого символа или 0, если ввод пустой
    inp = input()
    memory[data_ptr] = ord(inp[0]) if inp else 0
...

Это еще не все. Осталось самое сложное – обработка циклов (они же играю роль ветвлений в BF). Открывающая квадратная скобка «[« в code – команда, говорит нам, что нужно проверить текущую ячейку памяти в memory на равенство нулю. Если нуль, то прыгаем за пределы цикла – на соответствующую закрывающую скобку «]». Если не нуль, то дальше просто выполняем тело цикла. Когда натакливаемся на «]», тут тоже нужно проверить условие цикла, и если оно даст не ноль – вернуться к началу цикла, а иначе продолжить выполнять код дальше за скобкой.

Каждый раз во время выполнения кода искать нужную скобку – неудобно и медленно. Можно оптимизировать код, заранее пройдя по нему и запомнив, где какая скобка и где ее подруга – парная скобка. Для этого заведем словарь bracket_map. Для учета вложенности будем пользоваться структурой данных – стэк (из обычного списка):

# ключ и значения - индекс байта в коде
# по любой скобке находим ей пару
bracket_map = {}
stack = []
for pos, symbol in enumerate(code):
    # открыли скобку - положим на вершину стэка ее позицию
    if symbol == '[':
        stack.append(pos)
    elif symbol == ']':
        # вершина стэка - как раз наша парная скобка - была последней
        # удалим ее с вершины
        last_open_pos = stack.pop()
        # создадим парные записи
        bracket_map[pos] = last_open_pos
        bracket_map[last_open_pos] = pos

На иллюстрации ниже показано, что хранит этот словарь на примере простой программы.

Схема скобок для Hello world

Теперь мы готовы добавить в цикл исполнения обработку скобок. Просто проверяем условие, и когда нужно перемещаем указатель текущий команды на парную скобку: из начала цикла в конец, если ячейка содержит 0, и из конца цикла в начало, если не 0:

...
elif command == '[':
    # начало цикла - проверка текущей ячейки
    if not memory[data_ptr]:  # == 0
        # значит надо перейти на ячейку за соответствующей ей закрывающей скобкой
        instr_ptr = bracket_map[instr_ptr]
elif command == ']':
    # проверяем тоже условие, если выполняется, то скачем на начало цилка
    if memory[data_ptr]:  # не ноль
        # перемещаем на конец цикла
        instr_ptr = bracket_map[instr_ptr]

Готово! Интерпретатор завершен! Выполним простую программу hello_world.bf:

 ++++++++++[>+++++++>++++++++++>+++>+<<<<-]>++
 .>+.+++++++..+++.>++.<<+++++++++++++++.>.+++.
 ------.--------.>+.>.
(venv) ➜  bf python bf_interpretator.py hello_world.bf
Hello World! 

Работает! А вот как вам сортировка вставками на Brainfuck:

>>+>,[
    <[
        [>>+<<-]>[<<+<[->>+[<]]>>>[>]<<-]<<<
    ]>>[<<+>>-]<[>+<-]>[>>]<,
]<<<[<+<]>[>.>]

Запускаем. Пишем по очереди символы по одному, энтер после каждого. Чтобы завершить ввод массива – просто энтер без символа:

(venv) ➜  bf python bf_interpretator.py isort.bf      
8
4
6
1
9

14689  

Код программы и примеры загрузил на gist.

Компилятор Brainfuck на Python

Компилятор берет исходный код и превращает его в машинный исполняемый код. Не бойтесь, мы не будем писать в сложные по формату исполняемые файлы ассемблерные команды в виде байт. Поступим хитро. Сначала транслируем программу на BF в код на языке С, а потом обычным компилятором С транслируем код С в исполняемый файл.

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

PRELUDE = """// brainfuck generated:
#include <stdio.h>
int main() {
    const int mem_size = 30000;
    char mem[mem_size];
    memset(mem, 0, mem_size);
    int cur = 0;
"""

ENDING = """
    return 0;
}
"""

Далее код компилятора BF в Си. Все по таблице из Википедии:

def compile_to_c(bf_code):
    instr_ptr = 0  # индекс текущей инструкции кода (code)

    indent = 4  # смещение пробелами - для красоты
    c_code = PRELUDE

    # выполняем - пока не вылетели за пределы кода
    for command in bf_code:
        content = ''
        if command == '+':
            content = 'mem[cur]++;'
        elif command == '-':
            content = 'mem[cur]--;'
        elif command == '>':
            content = 'cur++;'
        elif command == '<':
            content = 'cur--;'
        elif command == '.':
            content = 'putchar(mem[cur]);'
        elif command == ',':
            content = 'mem[cur] = getchar();'
        elif command == '[':
            content = 'while(mem[cur]) {'
        elif command == ']':
            content = '}'

        instr_ptr += 1  # сдвигаем к следующей команде

        if content:
            if command == ']':
                indent -= 4
            c_code += ' ' * indent + content + '\n'
            if command == '[':
                indent += 4

    c_code += ENDING
    return c_code

Сохраняем С-код на диск и вызывем компилятор cc:

c_code = compile_to_c(code)
c_file = f'{name}.c'
with open(c_file, 'w') as f:
    f.write(c_code)
os.system(f'cc {c_file} -o {name}.out')

Проверка:

(venv) ➜  bf python bf_compiler.py hello_world.bf
...тут какое-то предупреждение от компилятора...

(venv) ➜  bf ./hello_world.bf.out 
Hello World!

Код на Си получится примерно такой (я сократил его значительно, чтобы не занимать много места в статье):

// brainfuck generated:
#include <stdio.h>
int main() {
    const int mem_size = 30000;
    char mem[mem_size];
    memset(mem, 0, mem_size);
    int cur = 0;
    mem[cur]++;
    mem[cur]++;
    mem[cur]++;
    mem[cur]++;
    mem[cur]++;
    while(mem[cur]) {
        cur++;
        mem[cur]++;
        mem[cur]++;
        mem[cur]++;
        cur++;
        mem[cur]++;
        cur--;
        cur--;
        cur--;
        cur--;
        mem[cur]--;
    }
    cur++;
    mem[cur]++;
    mem[cur]++;
    putchar(mem[cur]);
    cur++;
    mem[cur]++;
    putchar(mem[cur]);
    mem[cur]++;
    mem[cur]++;
    mem[cur]++;
    mem[cur]++;
    mem[cur]++;
    mem[cur]++;
    putchar(mem[cur]);
    cur++;
    putchar(mem[cur]);

    return 0;
}

Код компилятора в том же самом gist.

Замечания. В это варианте запятая будет работать не так, как в интерпретаторе выше. Поэтому код с сортировкой будет вести себя иначе. И второе: проверял компилятор на своей системе macOS, на Windows компилятор скорее всего не будет работать, если только вы его не запускаете из подсистемы Linux или Cygwin или подобных.

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

О поиске в словарях

При разборе вложенных структур из словарей и списков (например, конфигов), удобно пользоваться блоком try-except.

Ловим IndexError, если индекс отсутствует в списке, и KeyError, если ключ отсутствует в словаре. Однако, лучше ловить LookupError, который является предком обоих исключений:

>>> issubclass(KeyError, LookupError)
True
>>> issubclass(IndexError, LookupError)
True

Пример:

config = {}

try:
    admin = config['db'][0]['admins']['list'][0]
except LookupError:
    admin = 'all'

Независимо от того, не найден ли будет какой-то ключ словаря или индекс списка – будет поймана одна и та же ошибка LookupError.

Альтернативно, вы можете сразу обновлять записи словаря (если они не найдены) методом dict.setdefault(key, default). Этот метод проверяет, есть ли ключ в словаре, если его нет, то в словарь добавляется значение по умолчанию, и оно же возвращается. А если ключ был в словаре, то вернется значение по этому ключу. Поэтому такой неуклюжий код:

if 'workers' not in config:
    config['workers'] = 8
workers = config['workers']

Может быть переписан как:

workers = config.setdefault('workers', 8) 

Заметьте, что повторный вызов с другим default не поменяет уже записанное в первый раз значение:

>>> d = {}
>>> d.setdefault('foo', 10)
10
>>> d.setdefault('foo', 20)
10

Также, вам будет интересно почитать про defaultdict, который вам создает в себе записи при доступе к ним.

Красивого всем кода!

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Стратегия на Python

​​«Расскажите про любой шаблон проектирования на ваш выбор.»

Случалось слышать такое на собеседованиях? Большинство людей в этот момент начинают рассказывать про синглтон (одиночку). Потому что он… простой? Да, вообще-то не очень. Попробуйте сходу вспомнить, как там реализовать его через метакласс. Да и часто ли приходится? Скорее всего вы пользуетесь уже готовым кодом для синглтона. Его даже называют «анти-паттерном», потому что он часто маскирует плохой дизайн кода, вызывает проблемы при тестировании и нарушает принцип единственной отвественности класса (и порождает себя, и делает какую-то работу). А еще, он может вызывать проблемы с многопоточностью или «многопроцессностью» в случае с Python. Поэтому хвастать знанием синглотона – не лучшая стратегия на собеседовании…

Стратегия выбора работника

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

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

Реализация этого шаблона может быть не только объектная, но и функциональная. С последней и начнем:

# стратегия печатать на экран
def console_writer(info):
    print(info)

# стратегия выводить в файл
def file_writer(info):
    with open('log.txt', 'a') as file:
        file.write(info + '\n')

def client(writer):
    writer('Hello world!')
    writer('Good bye!')

# пользователь выбирает стратегию
if input('Write to file? [Y/N]') == 'Y':
    client(writer=file_writer)
else:
    client(writer=console_writer)

Стратегия выбирается пользователем, а функция client даже не знает, какой вариант алгоритма ей дадут. Она знает лишь то, что writer(info) – это некая функция, принимающая строку (это и есть общий интерфейс для всех стратегий). Таким образом, мы делегируем работу стратегиям, скрывая детали реализации каждой из них.

В объектном варианте:

class Adder:
    def do_work(self, x, y):
        return x + y

class Multiplicator:
    def do_work(self, x, y):
        return x * y

class Calculator:
    def set_strategy(self, strategy):
        self.strategy = strategy

    def calculate(self, x, y):
        print('Result is', self.strategy.do_work(x, y))

calc = Calculator()
calc.set_strategy(Adder())
calc.calculate(10, 20)

calc.set_strategy(Multiplicator())
calc.calculate(10, 20)

Мы обеспечили горячую заменяя алгоритмов для класса Calculator. Для простоты, здесь я не применял наследование (спасибо динамической природе Python), но в серьезных проектах, вам следовало бы написать что-то подобное:

from abc import ABC, abstractmethod

class BaseStrategy(ABC):
    @abstractmethod
    def do_work(self, x, y):
        pass

class Adder(BaseStrategy):
    def do_work(self, x, y):
        return x + y

class Multiplicator(BaseStrategy):
    def do_work(self, x, y):
        return x * y

class Calculator:
    def set_strategy(self, strategy: BaseStrategy):
        self.strategy = strategy

    def calculate(self, x, y):
        print('Result is', self.strategy.do_work(x, y))

Здесь мы создаем общий интерфейс стратегий BaseStrategy – как абстрактный класс ABC. Далее в каждой стратегии реализуем этот интерфейс.

Надеюсь, было полезно. Если хотите еще больше подробностей, то читайте 1, 2, 3.

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Ключ сортировки key

Методы sort, sorted могут принимать именованный аргумент key. Он должен быть функцией (или чем-то другим вызываемым – callable) с одним аргументом. Смысл key в том, что он вызывается ровно один раз для каждого из элементов списка (итератора и т.п.), которой мы сортируем, и указывает порядок сортировки: элементы выстраиваются ровно в том порядке, в каком бы выстроился сортированный список результатом вызова key на всех элементах:

  1. Применить key ко всем элементам
  2. Отсортировать результаты key по порядку, используя обычное сравнение «больше-меньше» для (чисел, строк и т.п.)
  3. Выстроить исходные данные согласно этому порядку.

key нужен, чтобы либо сортировать данные по нестандартному признаку или нескольким признакам сразу.

С числами все понятно: 10 > 6, 5 < 7. Строки сортируются лексикографически (как статьи в словаре: А < АА < ААА < ААБ < ААВ < АБ < Б < … < ЯЯЯ). А вот сортировка по длине строки потребует использовать key , потому что признак уже нестандартный:

>>> sorted(['Wolf', 'Sparrow', 'Cat'], key=len)
['Cat', 'Wolf', 'Sparrow']

>>> len('Cat')
3
>>> len('Wolf')
4
>>> len('Sparrow')
7

Или другой пример. У нас есть список координат точек [(x, y), ...]. Хотим расположить их по расстоянию от начала координат (0, 0):

pts = [(10, 20), (-100, 150), (0, 0), (40, -30)]

print(sorted(pts, key=lambda p: p[0] ** 2 + p[1] ** 2))

# [(-100, 150), (40, -30), (10, 20), (0, 0)]

Но, что если у нас, скажем, список кортежей? По умолчанию (стандартно) кортежи сравниваются сначала по первому элементу, а потом, если первые равны – по второму, и так далее. Если нужно игнорировать первый элемент и сразу сравнивать по второму – это уже и есть нестандартный признак.

drinks = [
    # напиток, цена
    ('Juice', 100),
    ('Beer', 200),
    ('Soda', 50),
    ('Cocktail', 400),
    ('Water', 20)
]

print(sorted(drinks, key=lambda drink: drink[1]))
# [('Water', 20), ('Soda', 50), ('Juice', 100), ('Beer', 200), ('Cocktail', 400)]


# без key:
print(sorted(drinks))  # отсортирует по названию напитка
# [('Beer', 200), ('Cocktail', 400), ('Juice', 100), ('Soda', 50), ('Water', 20)]

Бонус: если хотите поменять порядок сортировки на обратный, можно либо в лямбде поставить минус перед возвращаемым значениям, но лучше в sorted передать reverse=True.

print(sorted(drinks, key=lambda drink: -drink[1]))
# или лучше
print(sorted(drinks, key=lambda drink: drink[1], reverse=True))
# [('Cocktail', 400), ('Beer', 200), ('Juice', 100), ('Soda', 50), ('Water', 20)]

Сортировка по нескольким признакам сразу

Задача на примере тех же напитков. Отсортировать сначала по числу букв в названии, а если название одинаковой длины, то отсортировать по цене от дорогих к дешевым:

print(sorted(drinks, key=lambda dr: (len(dr[0]), -dr[1])))

# [('Beer', 200), ('Soda', 50), ('Juice', 100), ('Water', 20), ('Cocktail', 400)]

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

>>> list(map(lambda dr: (len(dr[0]), -dr[1]), drinks))
[(5, -100), (4, -200), (4, -50), (8, -400), (5, -20)]

Видите, первым теперь идет длина строки, а потом цена с минусом. Поэтому первыми после сортировки пойдут элементы с четверкой в ключе, а среди двух (4, -200), (4, -50) порядок сохранится, потому что -200 < -50.

Модуль operator

Вместо лямбды можно взять одну из библиотечных функций из модуля operator. Есть несколько вариантов для разных ситуаций.

Функция itemgetter(i) берет i-тый элемент кортежа или списка (или ищет по ключу i в dict):

from operator import itemgetter

print(sorted(drinks, key=itemgetter(1)))
# [('Water', 20), ('Soda', 50), ('Juice', 100), ('Beer', 200), ('Cocktail', 400)]

Для словарей:

# преобразуем список кортежей в список словарей
drinks_dict = [{'n': name, 'pr': price} for name, price in drinks]  
print(drinks)

# вывод: [{'n': 'Juice', 'pr': 100}, {'n': 'Beer', 'pr': 200}, {'n': 'Soda', 'pr': 50}, {'n': 'Cocktail', 'pr': 400}, {'n': 'Water', 'pr': 20}]

print(sorted(drinks, key=itemgetter('pr')))

# вывод: [{'n': 'Water', 'pr': 20}, {'n': 'Soda', 'pr': 50}, {'n': 'Juice', 'pr': 100}, {'n': 'Beer', 'pr': 200}, {'n': 'Cocktail', 'pr': 400}]

Теперь представим, что у нас есть класс Drink, и нужно сортировать по атрибуту price. Это можно сделать лямбдой или функцией attrgetter, которая получает атрибут объекта по имени этого атрибута:

class Drink:
    def __init__(self, name, price):
        self.name = name
        self.price = price
    def __repr__(self):
        return f'Drink("{self.name}", {self.price})'

drinks_cls = [
    # напиток, цена
    Drink('Juice', 100),
    Drink('Beer', 200),
    Drink('Soda', 50),
    Drink('Cocktail', 400),
    Drink('Water', 20)
]

print(sorted(drinks_cls, key=lambda drink: drink.price))

# или
from operator import attrgetter
print(sorted(drinks_cls, key=attrgetter('price')))

Еще есть methodcaller, она вызывает метод по имени и использует результат его, как ключ для сортировки.

from operator import methodcaller

sorted(items, key=methodcaller('get_reserve', category='home'))

# тоже самое что:

sorted(items, key=lambda item: item.get_reserve(category='home'))

Исследование производительности

Вариант с лямбдой немного медленнее (потому что операторы написаны на Си, а лямбду – мы пишем на Python). Проведем тесты производительности:

from random import shuffle
from timeit import timeit
from operator import itemgetter

data = [{'ident': x, 'value': 'foo'} for x in range(1000)]
shuffle(data)

def sort_itemgetter(data):
    data.sort(key=itemgetter('ident'))

def sort_lambda(data):
    data.sort(key=lambda it: it['ident'])

print('sort_itemgetter:', timeit('sort_itemgetter(list(data))', globals=globals(), number=10000))
print('sort_lambda:', timeit('sort_lambda(list(data))', globals=globals(), number=10000))

# sort_itemgetter: 1.6157471220000001
# sort_lambda: 1.8793544059999998

Потому что:

ig = itemgetter('ident')
la = lambda it: it['ident']
di = {'ident': 10}

print('itemgetter:', timeit('ig(di)', globals=globals(), number=1000000))
print('lambda:', timeit('la(di)', globals=globals(), number=1000000))

# itemgetter: 0.083
# lambda: 0.11

itemgetter быстрее, чем lambda, ибо он написан на Си.

Смотрите, как вам удобнее. Лично мне нравится все-таки вариант с лямбдами, потому что в нем меньше возможности ошибиться, так как нет строк, зато работает авто-дополнение от среды разработки.

min и max

Методы min и max также поддерживают key. Они вернут соответственно элемент, у которого key вернет наименьшее или наибольшее значение. На примере длины строк:

names = ['Wolf', 'Sparrow', 'Cat']
min(names, key=len)  # 'Cat'
max(names, key=len)  # 'Sparrow'

Самая ближняя от начала координат точка и самая дальняя:

pts = [(10, 20), (-100, 150), (0, 0), (40, -30)]
min(pts, key=lambda p: p[0] ** 2 + p[1] ** 2)  # (0, 0)
max(pts, key=lambda p: p[0] ** 2 + p[1] ** 2)  # (-100, 150)

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈