Импорт модулей из разных мест

Несложно импортировать встроенный модуль или пакет, установленный через pip, или тот, который лежит в директории с нашим кодом (import something). Но что если нужно импортировать код из произвольного места? Конечно, можно было бы скопировать код оттуда в своей проект, но так не рекомендуется делать. Есть и другие решения.

В модуле sys есть переменная path. Она содержит список путей, в которых Python ищет названия модулей для импорта. Пожалуйста, не путайте sys.path и переменную окружения PATH (которая, кстати, доступна через os.environ['PATH']). Это разные вещи, последняя не имеет отношения к поиску модулей Python.

>>> import sys
>>> sys.path
['', '/usr/local/Cellar/python@3.8/3.8.1/Frameworks/Python.framework/Versions/3.8/lib/python38.zip', ..., '/usr/local/lib/python3.8/site-packages']

Пустая строка в начале означает текущую рабочую директорию (pwd).

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

import sys
sys.path.insert(0, '/Users/you/Projects/my_py_lib')
import my_module  # этот модуль лежит в my_py_lib

Порядок тут важен. Нельзя сделать сначала import, потому что на момент импорта my_module система еще не знает, где его можно найти.

import sys
import my_module  # ModuleNotFoundError

sys.path.insert(0, '/Users/you/Projects/my_py_lib')  # поздно

Модуль site

Функция site.addsitedir тоже модифицирует sys.path, добавляя путь в конец списка. Еще она делает некоторые дополнительные вещи, но мы их не касаемся. Пример:

import site
site.addsitedir('/Users/you/Projects/my_py_lib')

import my_module

Также, набрав команду python3 -m site в командной строке, вы можете узнать пути для импорта в текущим интерпретаторе Python.

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

PYTHONPATH

PYTHONPATH – переменная окружения, которую вы можете установить перед запуском интерпретатора. Будучи заданной, она также влияет на sys.path, добавляя пути поиска модулей в начало списка.

На Windows можно использовать команду set. Если надо задать два и более путей, разделите их точкой с запятой:

set PYTHONPATH=C:\pypath1\;C:\pypath2\
python -c "import sys; print(sys.path)"

# Пример вывода:
['', 'C:\\pypath1', 'C:\\pypath2', 'C:\\opt\\Python36\\python36.zip', 'C:\\opt\\Python36\\DLLs', 'C:\\opt\\Python36\\lib', 'C:\\opt\\Python36', ..., 'Python36\\lib\\site-packages\\Pythonwin']

На Linux и macOS можно использовать export. Два и более путей разделяются двоеточием:

export PYTHONPATH='/some/extra/path:/foooo'
python3 -c "import sys; print(sys.path)"

# Пример вывода
['', '/some/extra/path', '/foooo', ...]

Или даже в одну строку:

PYTHONPATH='/some/path' python3 -c "import sys; print(sys.path)"

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

PyCharm

Если дополнительные пути заранее известные (не динамические), то в IDE обычно есть возможность задать их из настроек. Покажу на примере PyCharm 2019-2020.

Способ 1

Идем в настройки – Project Interpreter – Нажимаете на выпадающий список сверху – Show All.

project interpreter - show all

Там находите в списке нужный интерпретатор (тот, что задействован в текущем проекте) и внизу нажимаете иконку с папками.

python list

Затем нажимаете на плюсик и добавляете нужные папки, ОК.

Способ 2

Идем в настройки – Project: ваш проект – Project Structure – Add Content Root.

Способ 2

Таким образом, у вас будут работать все фишки IDE для импортированных по сторонним путям модулей, но код будет запускаться корректно только из этой IDE, а чтобы запустить его из-вне, например из терминала, придется все равно задать PYTHONPATH.

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

Комплексные числа в Python. Бонус: фрактал

В Python есть встроенный тип данных complex, который моделируют комплексные числа. По-моему, теория комплексных чисел – настоящий прорыв в математике, оказавший колоссальное влияние на современную физику. Неудивительно, что комплексные числа оказались в стандартной библиотеке такого языка, как Python.

Фрактал

Ко́мпле́ксные чи́сла — числа вида a + bi, где a, b — вещественные числа, i — мнимая единица, то есть число, для которого выполняется равенство: i2 = -1.

Графическое представление комплексного числа
Графическое представление комплексного числа

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

В Python комплексное число состоит из пары чисел с плавающей запятой, которые отвечают за реальную и мнимые части. В исходнике на Си – это структура из пары чисел типа double. (Вы же помните, что float в Python это числа двойной точности?):

typedef struct {
    double real;
    double imag;
} Py_complex;

✅ Давайте посмотрим, какими способами мы можем задать комплексное число. Во-первых, с помощью встроенной функции complex(real[, imag]):

>>> complex()   # нуль! (комплексный)
0j
>>> complex(1)   # из обычного числа int или float (мнимая часть будет 0)
(1+0j)
>>> complex(2, 3)   # из пары чисел (реальная и мнимая части)
(2+3j)
>>> complex('2+3j')  # из строки (без пробелов)!
(2+3j)

❌ Когда создаете комплексное число из строки, то в ней не должно быть пробелов, иначе будет ошибка ValueError!

✅ Во-вторых, что очень круто, комплексное число можно задать особым синтаксисом в форме a+bj. Примеры:

>>> 8+5j
(8+5j)
>>> -1-1j
(-1-1j)
>>> 0j
0j
>>> -4.4e+5 + 1.5e+6j
(-440000+1500000j)

❌ Но! Нельзя задавать их так:

>>> 1+j
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'j' is not defined
>>> a = 5
>>> 2 + aj
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'aj' is not defined

В первом случае надо писать 1j, вместо j, потому что j – это может быть название какой-то иной переменной, ведь имя j не зарезервировано под мнимую единицу. Чтобы задать мнимое число, обязательно требуется перед j ставить цифры.

Во втором случае, мы желаем задать мнимую часть через переменную, но Python думает, что у нас должна быть переменная с именем aj, которой нет. Правильно писать в таком случае a*1j.

✅ Правильные варианты:

>>> 1+1j
(1+1j)
>>> a = 5
>>> 2 + a * 1j
(2+5j)

Кстати, буква j может быть и заглавной J.

Какие свойства есть у типа complex?

Можно извлекать из комплексного числа его мнимую (imag) и реальную (real) части – это обычные float:

>>> z = -3+7j
>>> z.real, z.imag
(-3.0, 7.0)
>>> z == z.real + z.imag * 1j
True

Комплексно-сопряженное число – то же самое, только с другим знаком у мнимой части:

>>> z.conjugate()
(-3-7j)
>>> (4-1j).conjugate()
(4+1j)

Модуль комплексного числа – фактически длина вектора на комплексной плоскости – вычисляется обычной функцией abs:

>>> abs(4+3j)
5.0

Операции

✅ К комплексным числам применимы обычные арифметический операторы, такие как +, -, *, /.

Как вы догадались из формы синтаксиса 1+2j, можно без проблем складывать комплексные числа с обычными вещественными float или целыми int. Т.е. полное комплексное число в этой форме представляется суммой действительного числа и чисто мнимого. Аналогично, умножение комплексного числа на вещественное просто масштабирует пропорционально его компоненты на это число.

Пару комплексных чисел можно складывать и вычитать. Это просто, они работают подобно двумерным векторам на плоскости. Комплексные числа можно умножать и делить. Тут математика несколько сложнее, не буду повторять ее, читайте вики. А примеры кода вот:

>>> z1 = 3 + 4j
>>> z2 = 5 - 2j
>>> z1 + z2
(8+2j)
>>> z1 - z2
(-2+6j)
>>> z1 * z2
(23+14j)
>>> z1 / z2
(0.24137931034482757+0.896551724137931j)

Не забывайте ставить скобки:

>>> 1+2j * 5-4j
(1+6j)
>>> (1+2j) * (5-4j)
(13+6j)

❌ Целочисленные деления, взятие остатка и подобное не применимы к комплексным числам.

✅ Комплексные числа можно сравнивать на равенство и неравенство.

>>> 1+2j == 2j+1
True
>>> 3 + 4j != 2 - 2j
True

❌ Но нельзя к ним применять знаки больше, меньше.

>>> 1j > 2j
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: '>' not supported between instances of 'complex' and 'complex'

✅ Можно возводить одно комплексное число в степень другого следующими способами*:

>>> z1 ** z2
(3046.7438186304985+19732.04597993193j)
>>> pow(z1, z2)
(3046.7438186304985+19732.04597993193j)

Комплексный 0 в степени комплексного 0 даст… барабанная дробь… единицу:

>>> 0j ** 0j
(1+0j)

*) О многозначности замолвлено будет ниже, математики приберегите свои помидоры, пока не дочитаете до конца.

cmath

Модуль, который отвечает за стандартные функции над комплексными числами называется cmath (документация на английском). Обычный math не умеет извлекать корень из минус единицы, а cmath – запросто:

>>> import math, cmath
>>> math.sqrt(-1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: math domain error
>>> cmath.sqrt(-1)
1j

Рассмотрим основные функции из cmath:

cmath.phase(x) – фаза (или у нас она более известна, как аргумент Arg z) – эквивалент math.atan2(x.imag, x.real) – иными словами, это угол поворота φ вектора на комплексной плоскости. Результат лежит в промежутке [-ππ]. При этом разрез на комплексной области (т. е. луч, через который результат функции разрывается и перепрыгивает) выбирается вдоль отрицательной части реальной оси).

Представление комплексного числа в полярных координатах
Представление комплексного числа в полярных координатах
>>> phase(complex(-1.0, 0.0))
3.141592653589793
>>> phase(complex(-1.0, -0.0))  # минус ноль! подходим с другой стороны к точке разреза
-3.141592653589793

За модуль отвечает обычная abs (без cmath).

cmath.polar(x) – перевод комплексного числа в полярные координаты (кортеж из двух float): (abs(x), cmath.phase(x)).

cmath.rect(rphi) – наоборот выдает комплексное число по его полярным координатам – эквивалент: r * (math.cos(phi) + math.sin(phi)*1j).

cmath.sqrt(x) – корень квадратный из комплексного числа.

>>> cmath.sqrt(1j)
(0.7071067811865476+0.7071067811865475j)

Те, кто разбирается в математике сразу заметят, что многие функции от комплексной переменной – многозначны. (Видео о многозначных функциях) В частности у корня квадратного из комплексного числа всегда ровно два ответа. Но! Python всегда возвращает ровно одно значение. Он берет всегда «главную ветвь», а всякие вращения на 2πk/n остаются на совести программиста. Вот теория, как брать корень из комплексного числа. Я тщетно пытался нагуглить функцию, возвращающую множество значений корня n-й степени, и в итоге написал свою:

import cmath

def roots(z: complex, n):
    assert isinstance(n, int) and n > 1
    r, phi = cmath.polar(z)
    r **= 1 / n
    for k in range(n):
        yield cmath.rect(r, (phi + 2 * cmath.pi * k) / n)

z1, z2 = roots(1j, 2)
print(z1, z2)
print(cmath.isclose(z1 * z1, 1j))
print(cmath.isclose(z2 * z2, 1j))

# (0.7071067811865476+0.7071067811865475j) (-0.7071067811865477-0.7071067811865475j)
# True
# True
Разрез комплексной плоскости
Разрез комплексной плоскости

Функция cmath.isclose(ab*rel_tol=1e-09abs_tol=0.0) проверяет близки ли два комплексных числа между собой с некоторой точностью (относительной или абсолютной). Так как complex строится на базе чисел с плавающей точкой, то из-за ошибок округления редко можно получить точное равенство, приходится проверять близость. Код эквивалентен: abs(a-b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol). Числа NaN не близки ни к кому, включая самих себя. А inf и -inf близки только каждое само к себе.

cmath.exp(x),cmath.log(x[, base]), cmath.log10(x), cmath.acos(x), cmath.asin(x), cmath.atan(x), cmath.cos(x), cmath.sin(x), cmath.tan(x), cmath.acosh(x), cmath.asinh(x), cmath.atanh(x), cmath.cosh(x), cmath.sinh(x), cmath.tanh(x) –   обычный набор функций, только для комплексных чисел. Каждая из функций возвращает один результат. Думаю, если вам действительно нужны в работе комплексные функции, значит вы и так знаете, как достать все значения функций.

Множество Мандельброта

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

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

Реккурентное соотношение
Реккурентное соотношение

Иными словами мы берем каждую точку с = x + yj на выбранном участке комплексной плоскости и возводим в квадрат, потом снова прибавляем c, опять возводим в квадрат и так несколько раз. Смотрим, улетает ли результат в бесконечность. Если не улетает, значит точка принадлежит множеству, красим ее в черный, а если улетает, то красим в белый.

Нам понадобятся библиотеки для работы с изображениями и для полосы прогресса:

 pip install pillow tqdm 

Пишем код:

from PIL import Image
from tqdm import tqdm

W, H = 1024, 768  # размеры картинки
ITER = 100  # максимальное число итераций, чтобы убедиться расходится или нет формула в данной точке
LIMIT = 2.0  # предельное значение, выше которого уже наверняка расходится

img = Image.new('RGB', (W, H))

for px in tqdm(range(W)):
    for py in range(H):
        # преобразование координат
        x = px / W * 3 - 2  # x = -2..1
        y = py / H * 2 - 1  # y = -1..1

        color = (0, 0, 0)  # черный
        c = x + 1j * y  # смещение из координат
        z = 0j  # начальная точка
        for n in range(ITER):
            z = z ** 2 + c
            if abs(z) > LIMIT:  # разошлось
                color = (255, 255, 255)  # белый цвет
                break
        img.putpixel((px, py), color)

img.save('mand0.png')  # сохраним
img.show()  # покажем

Результат:

Фрактал в ЧБ
Фрактал в ЧБ

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

from PIL import Image
from tqdm import tqdm
import math

W, H = 1024, 768  # размеры картинки
ITER = 1000  # максимальное число итераций, чтобы убедиться расходися или нет формула в данной точке
LIMIT = 2.0  # предельное значение, выше которого уже расходится

img = Image.new('RGB', (W, H))

# создадим палитру от числа итераций
palette = [
    (
        int(255 * math.sin(i / 50.0 + 1.0) ** 2),
        int(255 * math.sin(i / 50.0 + 0.5) ** 2),
        int(255 * math.sin(i / 50.0 + 1.7) ** 2)
    ) for i in range(ITER - 1)
]
palette.append((0, 0, 0))  # последняя итерация - значит мы внутри - черный

for px in tqdm(range(W)):
    for py in range(H):
        # преобразование координат
        x = px / W * 3 - 2  # x = -2..1
        y = py / H * 2 - 1  # y = -1..1

        c = x + 1j * y  # смещение из координат
        z = 0j  # начальная точка
        for n in range(ITER):
            z = z ** 2 + c
            if abs(z) > LIMIT:  # разошлось
                break
        img.putpixel((px, py), palette[n])


img.save('mand1.png')  # сохраним
img.show()  # покажем

Вот итог:

Цветной фрактал
Цветной фрактал

Вот такая красота получается из простейшей формулы!

Можно пойти дальше и создать GIF анимацию, где мы постепенно приближаемся к деталям фрактала. Перед этим оптимизируем немного код. abs извлекает квадратный корень, что не обязательно. Лучше сделать так, для улучшения производительности:

                if (z * z.conjugate()).real > 4.0:  # вместо abs(z) > 2.0!
                    break

От кадра к кадру будем менять область отображения. Генератор фрактала для заданной области описан функцией:

def mandelbrot(w, h, palette, x1=-2, y1=-1, x2=1, y2=1):
    img = Image.new('RGB', (w, h))

    dx = x2 - x1
    dy = y2 - y1
    iters = len(palette)

    n = 0
    for px in range(w):
        for py in range(h):
            # преобразование координат
            x = px / w * dx + x1
            y = py / h * dy + y1

            c = x + 1j * y  # смещение из координат
            z = 0j  # начальная точка
            for n in range(iters):
                z = z ** 2 + c
                if (z * z.conjugate()).real > 4.0:  # разошлось
                    break
            img.putpixel((px, py), palette[n])
    return img

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

x, y, r = 0, 0, 5  # от
x_tar, y_tar, r_tar = -0.74529, 0.113075, 1.5e-6  # до

frames = []
for _ in tqdm(range(FRAMES)):
    frames.append(mandelbrot(W, H, palette, x - r, y - r, x + r, y + r))
    x += (x_tar - x) * 0.1
    y += (y_tar - y) * 0.1
    r += (r_tar - r) * 0.1

frames[0].save('mandel.gif', save_all=True, append_images=frames[1:], duration=60, loop=0)

Полный код генератора анимации здесь.

Анимация погружения в фрактал
Анимация погружения в фрактал

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

globals, locals, vars, dir – инспектируем переменные

Программист на Python может узнать, какие именно переменные определенны в данный момент в интерпретаторе. Переменные можно разделить на локальные и глобальные. Глобальные определены на верхнем уровне кода снаружи функций и классов (грубо говоря без отступов слева). Локальные переменные наоборот определены внутри своих зон видимости, ограниченных классами и функциями.

Функция globals() выдает словарь глобальных переменных (ключ – имя переменной). Функция locals() возвращает словарь только локальных переменных. Пример:

x, y = 5, 10

def test():
    y, z = 33, 44
    print('globals:', globals())
    print('locals:', locals())

test()

"""Вывод:
globals: {'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': ...>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': '/Users/.../vars.py', '__cached__': None, 'x': 5, 'y': 10, 'test': <function test at 0x107677280>}
locals: {'y': 33, 'z': 44}"""

Обратите внимание, что переменная y в locals() имеет другое значение, нежели чем в globals(). Это две разные переменные из разных областей, но внутри функции приоритет имеет локальная y.

Еще важно знать, что в список переменных входят не только простые переменные, которые вы определяете через знак присваивания, но и функции, классы и импортированные модули!

Через словари из locals() и globals() переменные можно не только читать, но и создавать, перезаписывать и удалять:

>>> x = 10
>>> globals()['x'] = 5
>>> x
5
>>> globals()['new_var'] = 10
>>> new_var
10
>>> del globals()['new_var']
>>> new_var
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'new_var' is not defined

vars()

vars() ведет себя как locals(), если вызвана без аргумента, а если с аргументом, то она просто получает __dict__ от аргумента. Если его нет у аргумента, то будет TypeError.

class Foo:
    def __init__(self):
        self.x = 5

f = Foo()
print(vars(f))  # {'x': 5}
print(vars(f) == f.__dict__)  # True

В глобальном контексте все три функции возвращают одно и тоже – глобальные переменные. Проверьте:

print(globals())
print(locals())
print(vars())
print(globals() == locals() == vars()) # True

dir()

Без параметров dir() возвращает список имен переменных. В глобальном контексте – глобальных переменных, в локальном – список имен локальных переменных.

def test():
    x = 10
    print(dir())  # ['x']

y = 10
test()
print(dir())  # ['__annotations__', ..., '__spec__', 'test', 'y']

Все рассмотренные выше функции являются встроенными и не требуют импортов.

P. S.

В отличие он некоторых других языков в Python блоки типа for, if, while, with не создают областей видимости (scope) для переменных, то есть переменная внутри и снаружи блока будет одна и та же:

x = 1
if True:
    x = 2
print(x)  # 2

Частая ошибка – затирание внешней переменной в цикле for:

i = 10
for i in range(5):  # затирает i
    ...
print(i)  # 4

Зоны видимости отделяются только функциями, классами и модулями. Здесь все переменные x – разные:

x = 1
class Foo:
    x = 2
    def method(self):
        x = 3
        return x
print(x, Foo.x, Foo().method())  # все 3 разные

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

>>> from math import pi
>>> pi, id(pi)
(3.141592653589793, 4465320624)
>>> pi = 3
>>> pi, id(pi)
(3, 4462262880)
>>> from math import pi
>>> pi, id(pi)
(3.141592653589793, 4465320624)

Казалось бы мы затерли pi, но мы затерли его лишь в глобальной области видимости. Повторно импортируя pi, мы получаем старую переменную с тем же адресом, иными словами мы достаем ее из builtin области в global.

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

​​Разбор URL

Функция urlparse из модуля urllib.parse разбирает URL на составные части: протокол, имя хоста, порт, путь, запрос и прочие.

Части URL
>>> u1 = urlparse('https://tirinox:1234@www.site.com:8080/some/page/index.html?page=2&action=login')
>>> u1
ParseResult(scheme='https', netloc='tirinox:1234@www.site.com:8080', path='/some/page/index.html', params='', query='page=2&action=login', fragment='')
>>> u1.scheme, u1.query, u1.netloc
('https', 'page=2&action=login', 'tirinox:1234@www.site.com:8080')

Имя хоста, порт, логин и пароль объединены в поле netloc, но их компоненты доступны для чтения по этим атрибутам:

>>> u1.username, u1.password, u1.hostname, u1.port
('tirinox', '1234', 'www.site.com', 8080)

Собрать обратно ParseResult в URL:

>>> u1.geturl()
'https://www.site.com:8080/some/page/index.html?page=2&action=login'

Если мы хотим в URL поменять какие-то части, удобно делать вот так:

>>> u1._replace(scheme='http').geturl()
'http://www.site.com:8080/some/page/index.html?page=2&action=login'

Однако таким способом нельзя поменять компоненты netloc, например, отдельно порт. netloc нужно менять целиком:

>>> u1._replace(netloc="user:password@www.site.com:8090").geturl()
'https://user:password@www.site.com:8090/some/page/index.html?page=2&action=login'

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @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 👈