Пишем на Питоне конвертер mp3 в opus для Linux, обработка исключений

newbie

Опубликован:  2020-03-11T08:00:25.915986Z
Отредактирован:  2020-03-14T09:07:58.393697Z
1000
Продолжаем пошаговую разработку конвертера mp3pus, в обзоре кратко поговорим о рефакторинге, рассмотрим исключительные ситуации, которые могут возникнуть в процессе использования программы конечным пользователем, и обработаем их таким образом, чтобы выхлоп программы был отчётлив, краток и понятен. Этот обзор - третья часть посвященного mp3pus цикла статей блога. Будет интересно...

1. В предыдущих сериях

Конвертер mp3pus - консольная утилита для получения группы файлов формата opus в заданном каталоге из группы файлов формата mp3 заданного каталога файловой системы. В блоге демонстрируется пошаговая разработка этого конвертера на языке Python3 в рабочем окружении Debian buster. Все посвященные mp3pus статьи можно отфильтровать по одноимённой метке mp3pus.

На текущий момент mp3pus имеет git-репозиторий и сценарий установки, таким образом программа доступна для скачивания и установки, и она будет работать на любой системе с ядром linux. Разрабатывается этот конвертер под свободной лицензией GNU GPLv.3.

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

2. Рефакторинг кода

Рефакторинг... Слово заставляет вздрагивать начинающих программистов. На самом деле ничего замысловатого в этом процессе нет. Сейчас я покажу процесс рефакторинга целевого класса Target, который находится в файле convert.py. Речь пойдёт о методе get_metadata, который был наспех разработан и отлажен в первом посвященном mp3pus обзоре, и на бумаге он выглядит так.

UAzHD2fis2.png

Что мне не нравится:

  1. Этот метод слишком громоздкий - содержит слишком много процедур;
  2. Ряд процедур этого метода (выделены на снимке экрана выше) повторяют одни и те же действия с разными тегами и ключами;
  3. Этот метод решает три различные типовые задачи.

Для начала разберёмся с рядом типовых процедур, их можно объединить функцией и в теле метода заменить вызовом этой функции. Даю классу Target ещё один дополнительный метод:

class Target:
    ...

    def _get_key(self, item, tag, key):
        attr = item.get(tag)
        if attr:
            return ' {0} "{1}"'.format(key, attr.text[0])

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

class Target:
    ...

    def get_metadata(self):
        item = mp3.MP3(self.target)
        self.album = self._get_key(item, 'TALB', '--album')
        self.genre = self._get_key(item, 'TCON', '--genre')
        self.title = self._get_key(item, 'TIT2', '--title')
        self.artist = self._get_key(item, 'TPE1', '--artist')
        self.date = self._get_key(item, 'TDRC', '--date')
        ...

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

class Target:
    ...

    def _get_tracknum(self, item):
        track = item.get('TRCK')
        if track:
            track = track.text[0]
            if track and '/' in track:
                track = track.split('/')
                self.tracknumber = " --comment tracknumber={}".format(track[0])
                self.tracktotal = " --comment tracktotal={}".format(track[1])
            elif track and '/' not in track:
                self.tracknumber = " --comment tracknumber={}".format(track)

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

class Target:
    ...

    def _get_comment(self, item):
        did = item.get('TXXX:DISCID')
        if did:
            self.comment = " --comment comment='{}'".format(
                did.desc + ': ' + did.text[0])
        comm = item.get('COMM::XXX')
        if comm:
            self.comment = " --comment comment='{}'".format(comm.text[0])

Имея эти два метода я могу переписать метод get_metadata следующим образом:

class Target:
    ...

    def get_metadata(self):
        item = mp3.MP3(self.target)
        self.album = self._get_key(item, 'TALB', '--album')
        self.genre = self._get_key(item, 'TCON', '--genre')
        self.title = self._get_key(item, 'TIT2', '--title')
        self.artist = self._get_key(item, 'TPE1', '--artist')
        self.date = self._get_key(item, 'TDRC', '--date')
        self._get_tracknum(item)
        self._get_comment(item)

В итоге всё вместе в моём текстовом редакторе выглядит следующим образом.

Zen5xasgcd.png

Главную цель рефакторинга я достиг. Метод get_metadata стал более лаконичным. Стоит сделать пару тестов.

UxKe6qy1wc.png

Посмотрим на метаданные полученных треков в формате opus.

CGWClj2Nl7.png

Rh2DBj7NsJ.png

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

3. Исключительные ситуации mp3pus

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

Все возникающие в процессе исполнения mp3pus исключительные ситуации я продемонстрирую на тестовом десктопе в виртуальной машине. Так как программа получила новый коммит, первым делом стоит её переустановить. Демонстрировать переустановку в рамках этого обзора я не буду, просто констатирую факт, что программа предварительно обновлена.

Первая исключительная ситуация возникает, если пользователь указал несуществующий каталог с входящими mp3 треками.

juDQ0v3vjY.png

На снимке экрана видно, что mp3pus в такой ситуации вообще ничего не выдал на терминал и завершил свой процесс. В сущности, ситуация безобидная, но у пользователя могут возникнуть вопросы и непонимание.

Вторая исключительная ситуация - пользователь указал несуществующий каталог для получаемых файлов opus.

oIu42eaLTP.png

В такой ситуации mp3pus перечислил базовые имена файлов mp3, ничего не кодируя, что естественно. Опять ситуация безобидная, но неприятная.

Третья исключительная ситуация - пользователь неправильно установил программу, в системе не установлен один из пакетов-зависимостей, у mp3pus в текущей инкарнации три зависимости:

  • python3-mutagen;
  • lame;
  • opus-tools.

Давайте посмотрим, как будет исполнена программа в этом случае, удаляю из системы lame и запускаю mp3pus.

fuzQYbh7AE.png

Теперь удаляю opus-tools и опять запускаю программу.

BBc4ROtmWz.png

Очередь mutagen.

lsNNAaUqIa.png

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

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

cc9pqY3H9Z.png

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

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

oNGSqa8B2j.png

Опять всё некрасиво...

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

4. Преобразование исполняемого файла

Напомню, что исполняемый файл программы - mp3pus - находится во вложенном каталоге bin и продублирован в рабочем каталоге программы с именем mp3pus.py, это обстоятельство имеет объяснения. Мне хотелось бы, чтобы в процессе работы над программой на следующих этапах разработки исполняемый файл никогда больше не редактировался, всегда оставался в неизменном виде и не участвовал в новых коммитах git-репозитория. Для этого поступлю следующим образом...

Создаю два новых файла.

touch mp3pus/main.py
touch mp3pus/system.py

NXbU3Q9BKd.png

В файл system.py вписываю функцию для обработки ошибок.

import os
import sys


def show_error(msg, code=1):
    print(
        os.path.basename(sys.argv[0]),
        'error',
        msg,
        sep=':',
        file=sys.stderr)
    sys.exit(code)

RIOdad7JiB.png

Ничего особенного в работе этой функции нет, она выводит на терминал сообщение об ошибке в заданной форме и прерывает процесс соответствующим системным вызовом.

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

def parse_args(version):
    ...

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

def start_the_process(arguments):
    print(arguments.input_dir)
    print(arguments.output_dir)

YZoIyW58R2.png

Ничто иное, как вызов этой функции я помещу в исполняемый файл - mp3pus.py в блоке try.

#!/usr/bin/env python3

from mp3pus import version
from mp3pus.main import parse_args, start_the_process
from mp3pus.system import show_error

try:
    start_the_process(parse_args(version))
except Exception as e:
    show_error(e)

qvvWhONi5r.png

Пробуем исполнить всё это великолепие...

vrgDFyAutS.png

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

5. Пользователь указал несуществующий каталог

Начнём с самого простого... Две исключительные ситуации, когда пользователь указал несуществующий каталог с файлами mp3 или несуществующий целевой каталог, куда необходимо сохранить файлы opus.

Модифицирую функцию start_the_process в файле main.py, вписываю две проверки существования переданных аргументами каталогов.

def start_the_process(arguments):
    if not os.path.exists(arguments.input_dir):
        raise OSError('{} does not exist'.format(arguments.input_dir))
    if not os.path.exists(arguments.output_dir):
        raise OSError('{} does not exist'.format(arguments.output_dir))

Xh3XBPePCR.png

Попробуем последовательно создать эти две исключительные ситуации.

1uTJ31haHc.png

tkml7R7XJs.png

Аха... На этот раз программа выдала предусмотренное программистом сообщение и прервала свой процесс. Отлично, следуем дальше...

6. В системе не установлена одна из зависимостей программы

Очевидно, чтобы избежать каверз с отсутствующими зависимостями, нужно научить mp3pus проверять наличие в системе необходимых ему программ. У нашего конвертера на текущий момент три зависимости. Первая - питоний модуль mutagen, проверить наличие в системе этого модуля достаточно просто. Запускаю интерпретатор и ставлю следующий эксперимент, импортирую модуль стандартной библиотеки:

>>> import importlib.util
>>>

Проверяю наличие в системе модуля по его имени:

>>> importlib.util.find_spec('mutagen')
ModuleSpec(name='mutagen', loader=<_frozen_importlib_external.SourceFileLoader object at 0x7f0935466550>, origin='/usr/lib/python3/dist-packages/mutagen/__init__.py', submodule_search_locations=['/usr/lib/python3/dist-packages/mutagen'])
>>> 

Несомненно, в моей системе mutagen установлен. Если методу find_spec передать имя несуществующего в системе модуля:

>>> importlib.util.find_spec('celery')
>>> importlib.util.find_spec('celery') is None
True
>>> 

Он вернёт None. Вся отладка выглядит так:

YXykzLUleW.png

Такая проверка меня полностью устроит, и выполнять её я буду при каждом импорте целевого класса Target. Создаю внутри вложенного каталога mp3pus новый каталог с именем convert, в этом только что созданном каталоге создаю новый файл с именем __init__.py и переношу файл convert.py сюда же.

qLFn4yHLmp.png

В только что созданный дандеринит пишу новый код.

import importlib.util

from ..system import show_error

if importlib.util.find_spec('mutagen') is None:
    show_error('python3 module mutagen is not installed')

nWt5dNkJgx.png

С mutagen разобрались. Что делать с lame и opus-tools? Опять лезу в интерпретатор и пробую найти в системе оба исполняемых файла, которые используются в mp3pus. Импортирую модуль стандартной библиотеки os.

>>> import os
>>>

Попробуем воспроизвести значение переменной окружения PATH.

>>> os.getenv('PATH')
'/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games'
>>>

Попробуем определить адрес исполняемого файла lame перебором каталогов переменной PATH.

>>> for each in os.getenv('PATH').split(':'):
...     lame = os.path.join(each, 'lame')
...     if os.path.exists(lame):
...         print(lame)
...         break
... 
/usr/bin/lame
>>> 

tsJjcCbq0W.png

Отлично! Аналогичным образом можно найти исполняемый файл opusenc. Понятно, что этот код будет использоваться как минимум два раза. Открываю файл mp3pus/system.py и дописываю ещё одну функцию - check_dep.

Xzewaoi78k.png

Имея такую функцию, я могу дописать в соответствующий дандеринит ещё две проверки.

yXWHPSBOWN.png

Теперь в файл main.py можно дописать пару новых процедур в теле функции start_the_process.

AQ5x8tuwN9.png

Каждый раз при вызове функции start_the_process, в момент импортирования целевого класса Target программа выполнит все три проверки. Тестировать этот код я буду на тестовой системе в виртуалке чуть позже, а пока займёмся следующими двумя исключительными ситуациями...

7. Файл mp3 удалён или является файлом другого формата

Да, поскольку процесс кодирования всех файлов заданного каталога занимает изрядную долю времени, можно предвидеть удаление некоторых файлов заданного каталога уже после запуска процесса mp3pus. Программа должна обойти эту каверзу и не споткнуться на имени удалённого файла, а продолжить конвертировать оставшиеся mp3. Дописываю в функцию start_the_process ещё пару процедур.

wVFLYmuoJN.png

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

Последняя исключительная ситуация - файл в заданном каталоге имеет расширение *.mp3, но по факту не является этим форматом. Давайте посмотрим, как на такие файлы отреагирует mutagen, создаю пустой файл с расширением mp3, затем запускаю интерпретатор и пробую пощупать этот файл с помощью используемого в mp3pus инструмента.

>>> from mutagen import MutagenError
>>> from mutagen.mp3 import MP3
>>> f = 'notmp3.mp3'
>>> try:
...     item = MP3(f)
... except MutagenError:
...     item = None
... 
>>> item is None
True
>>> 

Вся отладка выглядит так:

fSzWUNQN0D.png

Дописываю целевому классу Target, его методу get_metadata соответствующую процедуру.

class Target:
    ...

    def get_metadata(self):
        try:
            item = mp3.MP3(self.target)
            self.album = self._get_key(item, 'TALB', '--album')
            self.genre = self._get_key(item, 'TCON', '--genre')
            self.title = self._get_key(item, 'TIT2', '--title')
            self.artist = self._get_key(item, 'TPE1', '--artist')
            self.date = self._get_key(item, 'TDRC', '--date')
            self._get_tracknum(item)
            self._get_comment(item)
            self.is_mp3 = True
        except MutagenError:
            print(
                '{} is not mp3, passed'.format(os.path.basename(self.target)))

Здесь следует иметь ввиду, что в файле добавился один импорт.

from mutagen import mp3, MutagenError

А у класса Target появилось ещё одно свойство.

class Target:
    def __init__(self, filename, out_dir):
        ...
        self.is_mp3 = None

3hbkbvH860.png

Последний штрих... Дописываю в функцию start_the_process ещё несколько процедур.

itzpI0WUw9.png

Пробуем исполнить...

4EPO7RgQl3.png

Как видно на снимке экрана выше, mp3pus обошёл фэйк и выдал в терминал соответствующее предупреждение. Всё, все цели достигнуты...

8. Сохраняем изменения в git

Идея с жесткой ссылкой увы оказалась неработоспособной, поэтому перед сохранением изменений в git обновляю исполняемый файл.

cp mp3pus.py bin/mp3pus

Добавляю все изменения к следующему коммиту.

git add .

Делаю коммит.

git commit -m"Prevent errors"

Запушиваю последний коммит на сервер.

git push origin master

aLhFAyUmd1.png

Найти код программы соответствующий этому коммиту можно в моём профиле на github.com. А теперь идём на виртуалку, тестировать обновление...

9. Тестируем новый функционал на виртуальной машине

Перво-наперво необходимо обновить программу. Вхожу в каталог с git-репозиторием mp3pus, который был клонирован чуть раньше, в предыдущем выпуске блога, во время тестирования инсталляции пакета с pip.

cd py-workspace/mp3pus

Подтягиваю с сервера новый коммит.

git pull origin master

PO0WeOPf1G.png

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

sudo pip3 uninstall mp3pus

F0rUYlh70f.png

Ну и устанавливаем новый пакет из обновлённого git-репозитория.

sudo pip3 install .

uFeoOyh8Bm.png

Всё отлично, тестируем... Далее я покажу на каждую исключительную ситуацию один снимок экрана с реакцией mp3pus и без комментариев.

Z7HHjj3gGA.png

mmeB57sasi.png

5eDMQY8AY3.png

6qNWTp8AyB.png

z1vCBvE6PA.png

tBTqeCXNIT.png

uLSmsrWjos.png

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

10. Продолжение следует

Работа над mp3pus не закончена. Вероятно, что в следующем выпуске блога я расскажу, как можно поступить с вшитой в файл mp3 картинкой, найти её, извлечь и экспоритовать в файл opus. Продолжение следует, будет интересно...

Комментарии: