Статьи

Плохая практика Python: конкретный случай

Многие люди читают о хорошей практике Python, и есть много информации об этом в Интернете. Многие советы включены в книгу, которую я написал в этом году, «Руководство хакера по Python» . Сегодня я хотел бы показать конкретный пример кода, который я не считаю современным.

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

Прежде чем я начну, пожалуйста, не поймите дух этой статьи неправильно. Это никоим образом не является личной атакой на авторов и авторов (которых я не знаю). Кроме того, « шепот» — это фрагмент кода, который создается тысячами установок и хранит метрики годами. Хотя я могу утверждать, что считаю, что код не соответствует передовому опыту, он определенно работает достаточно хорошо и достоин многих людей.

тесты

Первое, что я заметил при попытке взломать шепотом , это отсутствие теста. Есть только один файл, содержащий тесты, имя test_whisper.pyи охват, который он обеспечивает, довольно низкий. Это можно проверить с помощью инструмента покрытия .

$ coverage run test_whisper.py
...........
----------------------------------------------------------------------
Ran 11 tests in 0.014s
 
OK
$ coverage report
Name           Stmts   Miss  Cover
----------------------------------
test_whisper     134      4    97%
whisper          584    227    61%
----------------------------------
TOTAL            718    231    67%

Хотя можно было бы подумать, что 61% — это не так уж и плохо, быстрый взгляд на реальный код теста показывает, что тесты не завершены. Почему я подразумеваю под неполным, что они, например, используют библиотеку для хранения значений в базе данных, но они никогда не проверяют, могут ли результаты быть получены и получены ли результаты точно. Вот веская причина, по которой никогда не следует слепо доверять проценту покрытия теста как метрике качества.

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

Нет PEP 8, нет Python 3

Кодекс не соответствует PEP 8. Пробег flake8 + взлом показывает 732 ошибки … Хотя это не влияет на сам код, это более болезненно взломать на него , чем на большинстве проектов Python.

Инструмент для взлома также показывает, что код не готов к работе с Python 3, поскольку используется только синтаксис Python 2.

Хороший способ исправить это — настроить tox и добавить несколько целей для проверок PEP 8 и Python 3. Даже если набор тестов не завершен, начиная с запуска flake8 без ошибок и нескольких модульных тестов, работающих с Python 3, следует представить проект в лучшем свете.

Не использовать идиоматический Python

Большая часть кода может быть упрощена с помощью идиоматического Python. Давайте рассмотрим простой пример:

def fetch(path,fromTime,untilTime=None,now=None):
  fh = None
  try:
    fh = open(path,'rb')
    return file_fetch(fh, fromTime, untilTime, now)
  finally:
    if fh:
      fh.close()

Этот кусок кода может быть легко переписан как:

def fetch(path,fromTime,untilTime=None,now=None):
  with open(path, 'rb') as fh:
    return file_fetch(fh, fromTime, untilTime, now)

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

Использование петель также может быть сделано более Pythonic:

for i,archive in enumerate(archiveList):
  if i == len(archiveList) - 1:
    break

может быть на самом деле:

for i, archive in enumerate(itertools.islice(archiveList, len(archiveList) - 1):

Это уменьшает размер кода и облегчает чтение кода.

Неправильный уровень абстракции

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

Возьмем create()функцию, это довольно очевидно:

def create(path,archiveList,xFilesFactor=None,aggregationMethod=None,sparse=False,useFallocate=False):
  # Set default params
  if xFilesFactor is None:
    xFilesFactor = 0.5
  if aggregationMethod is None:
    aggregationMethod = 'average'
 
  #Validate archive configurations...
  validateArchiveList(archiveList)
 
  #Looks good, now we create the file and write the header
  if os.path.exists(path):
    raise InvalidConfiguration("File %s already exists!" % path)
  fh = None
  try:
    fh = open(path,'wb')
    if LOCK:
      fcntl.flock( fh.fileno(), fcntl.LOCK_EX )
 
    aggregationType = struct.pack( longFormat, aggregationMethodToType.get(aggregationMethod, 1) )
    oldest = max([secondsPerPoint * points for secondsPerPoint,points in archiveList])
    maxRetention = struct.pack( longFormat, oldest )
    xFilesFactor = struct.pack( floatFormat, float(xFilesFactor) )
    archiveCount = struct.pack(longFormat, len(archiveList))
    packedMetadata = aggregationType + maxRetention + xFilesFactor + archiveCount
    fh.write(packedMetadata)
    headerSize = metadataSize + (archiveInfoSize * len(archiveList))
    archiveOffsetPointer = headerSize
 
    for secondsPerPoint,points in archiveList:
      archiveInfo = struct.pack(archiveInfoFormat, archiveOffsetPointer, secondsPerPoint, points)
      fh.write(archiveInfo)
      archiveOffsetPointer += (points * pointSize)
 
    #If configured to use fallocate and capable of fallocate use that, else
    #attempt sparse if configure or zero pre-allocate if sparse isn't configured.
    if CAN_FALLOCATE and useFallocate:
      remaining = archiveOffsetPointer - headerSize
      fallocate(fh, headerSize, remaining)
    elif sparse:
      fh.seek(archiveOffsetPointer - 1)
      fh.write('\x00')
    else:
      remaining = archiveOffsetPointer - headerSize
      chunksize = 16384
      zeroes = '\x00' * chunksize
      while remaining > chunksize:
        fh.write(zeroes)
        remaining -= chunksize
      fh.write(zeroes[:remaining])
 
    if AUTOFLUSH:
      fh.flush()
      os.fsync(fh.fileno())
  finally:
    if fh:
      fh.close()

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

Это означает, что вызывающая сторона должна указать путь к файлу, даже если она просто хочет, чтобы структура данных whipser хранилась в другом месте. StringIO()может использоваться для фальсификации обработчика файла, но он потерпит неудачу, если вызов fcntl.flock()не отключен — и в любом случае он неэффективен.

В коде много других функций, таких как, например , которые смешивают обработку файлов — даже делают что-то подобное — при манипулировании структурированными данными. Это определенно не очень хороший дизайн, особенно для библиотеки, поскольку оказывается, что повторное использование функции в другом контексте практически невозможно. setAggregationMethod() os.fsync()

Расовые условия

Есть условия гонки, например, в create()(см. Добавленный комментарий):

if os.path.exists(path):
  raise InvalidConfiguration("File %s already exists!" % path)
fh = None
try:
  # TOO LATE I ALREADY CREATED THE FILE IN ANOTHER PROCESS YOU ARE GOING TO
  # FAIL WITHOUT GIVING ANY USEFUL INFORMATION TO THE CALLER 🙁
  fh = open(path,'wb')

Этот код должен быть:

try:
  fh = os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL), 'wb')
except OSError as e:
  if e.errno = errno.EEXIST:
    raise InvalidConfiguration("File %s already exists!" % path)

чтобы избежать каких-либо условий гонки.

Нежелательная оптимизация

Ранее мы видели fetch()функцию, которая едва полезна, поэтому давайте посмотрим на функцию, которую она вызывает. file_fetch()

def file_fetch(fh, fromTime, untilTime, now = None):
  header = __readHeader(fh)
[...]

Первое, что делает функция — это читает заголовок из обработчика файла. Давайте посмотрим на эту функцию:

def __readHeader(fh):
  info = __headerCache.get(fh.name)
  if info:
    return info
 
  originalOffset = fh.tell()
  fh.seek(0)
  packedMetadata = fh.read(metadataSize)
 
  try:
    (aggregationType,maxRetention,xff,archiveCount) = struct.unpack(metadataFormat,packedMetadata)
  except:
    raise CorruptWhisperFile("Unable to read header", fh.name)
[...]

Первое, что делает функция — заглядывает в кеш. Почему там кеш?

На самом деле он кэширует заголовок на основе индекса на основе пути к файлу ( fh.name). За исключением того, что если кто-то, например, решит не использовать файлы и читы StringIO, он не имеет никакого атрибута имени. Так что этот путь к коду вызовет AttributeError.

Нужно установить поддельное имя вручную на StringIOэкземпляре, и оно должно быть уникальным, чтобы никто не мешал с кешем

import StringIO
 
packedMetadata = <some source>
fh = StringIO.StringIO(packedMetadata)
fh.name = "myfakename"
header = __readHeader(fh)

Кеш может действительно быть полезен при доступе к файлам, но он определенно бесполезен, когда файлы не используются. Но это не обязательно правда, что сложность (даже небольшая), которую добавляет кеш, того стоит. Я сомневаюсь , что большинство из шепота инструменты , основанные на давно запускать процессы, поэтому кэш , который действительно используется при доступе к файлам , это один управляется ядром операционной системы, и это один будет гораздо более эффективным , так или иначе, и совместно обрабатываются. Также нет срока действия этого кэша, который может привести к потере тонны используемой памяти.

Строки документации

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

Документация также не обновлена:

def fetch(path,fromTime,untilTime=None,now=None):
  """fetch(path,fromTime,untilTime=None)
[...]
"""
 
def create(path,archiveList,xFilesFactor=None,aggregationMethod=None,sparse=False,useFallocate=False):
  """create(path,archiveList,xFilesFactor=0.5,aggregationMethod='average')
[...]
"""

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

Дублированный код

И последнее, но не менее важное: в сценариях, предоставляемых whisper в его binкаталоге, много кода дублируется . Тезисы сценарии должны быть очень легкими и быть с помощью средства Setuptools , но они на самом деле содержат много (непроверенную) коды. Кроме того, часть этого кода частично продублирована из библиотеки, которая против DRY . console_scriptswhisper.py

Вывод

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

Многие из этих недостатков на самом деле и стали причиной того, что я начал писать «Руководство хакера по Python» год назад. Попадание в этот вид кода заставляет меня думать, что было действительно хорошей идеей написать книгу с советами по написанию лучшего кода на Python!