Конкурс Изучаем Python на практике. Пишем аналог утилит wc и split (для подсчета строк и разрезания текстовых файлов).

Статья для участия в Конкурсе программистов.
Это вторая статья из цикла "Изучаем Python на практике".
Первый пост: Изучаем Python на практике. Пишем чекер SSH серверов.
Смотрите комментарии к посту, если будут найдены ошибки или внесены исправления, буду публиковать их в комментариях, так как через некоторое время возможность редактирования поста пропадает.

В *nix подобных системах есть две утилиты: wc - для подсчета строк в файле, split для разрезания файлов. Однако split режет файлы по количеству байт, не учитывая содержимое.
Попробуем создать их аналог для работы с текстовыми файлами.
Сформулируем техническое задание.
Наша самописная утилита, назовем ее "filecut", должна обладать следующим функционалом:
  • уметь подсчитывать строки в указанном текстовом файле - пользователь вводит имя файла, ключ , получает количество строк в указанном файле;
  • уметь разрезать файл на части с заданным количеством строк - пользователь вводит имя файла, ключ -l целое число, где целое число - количество строк в разрезанных частях первоначального файла, получает количество строк в указанном файле;;
  • должна работать с файлами большого размера (до нескольких гигабайт).
Начнем с того, что программа должна уметь обрабатывать аргументы, переданные ей в командной строке.
Обычно, в таких простых скриптах используется модуль argparse, входящий в стандартную библиотеку, но я решил попробовать что то новое и использовал модуль , предназначенный для написания утилит командной строки на Python.

Не нарушая правила, рекомендуемые для выполнения скриптов, сначала выполним проверку запускается ли скрипт отдельно (в нашем случае, это единственный возможный вариант) и передаем управление в функцию main().
Python:
if __name__ == "__main__":
    main()
Над функцией main() есть строки вида @click.command() - это декораторы, они позволяют обертывать функции и, таким образом, изменять их функционал не меняя код внутри функции. В нашем случае несколько декораторов из модуля click позволяют обработать аргументы командной строки, вывести справку по командам, обработать ошибку при отсутствии обязательных аргументов.
Python:
@click.command()
@click.argument('filename')
@click.option(
    '--count', '-c', count=True,
    help='Count lines in file',
)
@click.option(
    '--lines', '-l',
    help='Lines number in cut parts of file',
)
@click.option(
    '--buffer', '-b', default=1000,
    help='Lines number in cut parts of file',
)
def main(filename, count, lines, buffer):
    """
    A file tool that that can:
    - count lines in file,
    - cut file on parts with a given number of lines.
    """
    if filename is not None and count:
        print('Counting line in file:', filename)
        lines_count = lines_counter(filename)
        print(f"In file {filename} {lines_count} lines")
    if filename is not None and lines is not None and buffer is not None:
        lines_number_cut(filename, lines, buffer)


if __name__ == "__main__":
    main()
@click.argument('filename') - обрабатывает аргумент отвечающий за получение обязательного параметра - имени файла. Не получив имени файла работа программы прерывается и выводится справочное сообщение с напоминанием, что для запуска программы нужно обязательно указать имя файла. Причем обработка ошибки происходит без нашего участия. Я нигде в коде не обрабатывал эту ошибку. Эти занимается модуль click.
@click.option('--count' - обрабатывает аргумент, отвечающий за подсчет строк в файле, '-c' указывает сокращенную запись аргумента, count=True - указывает, что после самого аргумента можно не вводить дополнительные параметры. Обычно такой аргумент называется ключом.
@click.option('--lines' - обрабатывает аргумент, указывающий, сколько нужно сохранять строк в результирующих файлах.
@click.option('--buffer' - обрабатывает аргумент, указывающий величину буфера записи, - об этом позже по тексту.
В функции main() строки if filename is not None and count: проверяют какие получены параметры при запуске, в данном случае если переменные, отвечающие за сохранение имени файла filename и параметра количества строк count не пустые, то вызывается функция подсчета строк в файле lines_counter(filename), а после ее работы печатается сообщение с результатом работы.
Если три параметра отвечающие за имя файла и количество строк в результирующем файле не пустые if filename is not None and lines is not None and buffer is not None: то выполняется функция производящая разбиение файла на несколько файлов с заданным количеством строк lines_number_cut(filename, lines, buffer), ей передаются три аргумента. В проверке присутствует и третий параметр, но его значение вводить необязательно, так, как в декраторе есть параметр default=1000 с дефолтным значением.
Таким образом после запуска скрипта мы получили и обработали параметры запуска и можем переходить к основному функционалу.

Функция, отвечающая за подсчет количества строк получилась простой, принимает только имя файла в качестве аргумента и возвращает количество строк:
Python:
def lines_counter(filename):
    line_count = 0
    for line in tqdm(read_file(filename), ascii=True, dynamic_ncols=True, total=line_count, unit=" lines"):
        line_count += 1
    return line_count
Все, что нужно это создать счетчик line_count = 0 для подсчета строк и пройти по файлу и после чтения текущей строки увеличиваем счетчик на единицу. В цикле выполняется проход по строкам исходного файла for line in tqdm(read_file(filename), тут появляется функция . Это модуль для создание прогрессбаров, позволяющий отображать ход выполнения циклов. К сожалению, я не смог его заставить отображать прогрессбар в классическом виде. Насколько я понял, это возможно только для итерируемых объектов у которых известна их величина. А в нашем случае, мы читаем файл с неизвестным количеством строк. Поэтому вместо прогресс будем видеть ход выполнения. Что тоже неплохо, особенно при обработке больших файлов.
За чтение файла отвечает функция:
Python:
def read_file(filename):
    try:
        with codecs.open(filename, 'r', encoding='utf-8', errors='ignore') as file:
            for line in file:
                yield line
    except IOError:
        print("Can't read from file, IO error")
        exit(1)
Функция с контекстным менеджером with и модулем из стандартной библиотеки codecs. Это позволяет обрабатывать текстовые файлы в разных кодировках и приводить все к кодировке UTF8 encoding='utf-8', заодно и игнорировать ошибки errors='ignore'.
Для реализации возможности чтения больших файлов чтение строк реализовано с помощью генератора yield. Если бы его не было, то все содержимое читалось бы в список, который бы хранился в оперативной памяти и, таким образом, возможность работы с большими гигабайтными файлами была бы закрыта. Yield возвращает по одной строке за цикл, который проходит последовательно по всем строкам файла, т.е. без цикла for line in tqdm(read_file(filename) он вернул бы только первую строку.
Функция, отвечающая за запись в файл:
Python:
def write_file(filename, data):
    try:
        with codecs.open(filename, 'a', encoding='utf-8', errors='ignore') as file:
            file.writelines(data)
        return True
    except IOError:
        print("Can't write to output file, IO error")
        exit(1)
Использует метод writelines, который принимает список строк.

Переходим к реализации следующей опции программы - разбивке файла на несколько частей с определенным количеством строк.
Python:
def lines_number_cut(filename, lines, buffer) -> None:
    lines_in_file, count_of_files, remain_lines = get_lines_cut_options(filename, lines)
    ranges_of_lines = get_ranges_of_lines(lines, count_of_files, remain_lines)
    print('File will cut on ' + str(count_of_files) + ' parts.')
    line_count = 1
    temp_1000_lines = []
    for line in tqdm(read_file(filename), ascii=True, dynamic_ncols=True, total=line_count, unit=" lines"):
            for key, value in ranges_of_lines.items():
                name = key + '.txt'
                start_line = value['start']
                end_line = value['end']
                if line_count >= start_line and line_count <= end_line:
                    temp_1000_lines.append(line)
                    if len(temp_1000_lines) > buffer:
                        write_file(name, temp_1000_lines)
                        del temp_1000_lines[:]
            line_count += 1
Начинаем с получения трех значений lines_in_file, count_of_files, remain_lines. lines_in_file - берется из аргумента, введенного пользователем, count_of_files и remain_lines рассчитываются, - count_of_files округляется, в remain_lines заносится остаток от деления:
Python:
def get_lines_cut_options(filename, lines):
    lines_in_file = lines_counter(filename)
    count_of_files = math.ceil(lines_in_file / int(lines))
    remain_lines = lines_in_file % int(lines)
    return lines_in_file, count_of_files, remain_lines
Считается количество строк lines_counter(filename) в исходном файле, исходя из этого, зная желаемое количество строк в результирующих файлах определяется их количество count_of_files = math.ceil(lines_in_file / int(lines)).
На следующем этапе создаются диапазоны для каждого исходящего файла, благодаря которым, можно будет вывод из исходного файла перенаправлять в целевой get_ranges_of_lines(lines, count_of_files, remain_lines). remain_lines указывает остаток строк в последнем файле, так как их количество будет отличаться от количества строк в предыдущих файлах.
Python:
def get_ranges_of_lines(lines, count_of_files, remain_lines):
    start_range = 1
    list_of_ranges = {}
    for current_file_number in range(count_of_files):
        if current_file_number <= count_of_files - 2:
            end_range = start_range + int(lines) - 1
        else:
            end_range = start_range + remain_lines
        lines_in_range = str(start_range) + '-' + str(end_range)
        list_of_ranges[lines_in_range] = {'start': start_range, 'end': end_range}
        start_range = start_range + int(lines)
    return list_of_ranges
Для каждого результирующего файла передаем его параметры - диапазон строк исходного файла, который будут скопированы при последующем цикле чтения, соответственно начало и конец диапазона и имя самого диапазона, которое в дальнейшем будет использовано для имени файла вида 100-200.txt. Функция возвращает словарь list_of_ranges.

Далее открываем исходный файл for line in tqdm(read_file(filename), опять с использованием модуля tqdm для отображения прогресса цикла выполнения.
Распаковываем словарь for key, value in ranges_of_lines.items().
Идем по строкам и проверяем последовательно входит ли строка в диапазон первого результирующих файлов
if line_count >= start_line and line_count <= end_line:
Если входит, то добавляем строку в заранее созданный до всех циклов список temp_1000_lines = [].
Почему в список, а не пишем напрямую в результирующий файл? Дело в том, что во время тестирования скрипта обнаружил, что запись в файл является "бутылочным горлышком", лимитирующим скорость работы программы, и запись каждой строки в файл отдельно - непозволительная роскошь, так как скорость записи варьировалась от нескольких строк до десятков строк в секунду, в зависимости от носителя на котором располагался файл. Поэтому строки из файла читаются, с помощью списка формируются в пакеты, по умолчанию стоит значение в 1000 строк. Эти пакеты сбрасываются на запись, а список очищается. Вот здесь и всплыла величина буфера, как необязательного аргумента, который можно передать в программу для ускорения записи. Скорость работы программы возрастает при увеличении буфера, но при этом возрастает и количество оперативной памяти используемой скриптом. Скорость будет возрастать до тех пор по не упрется в производительность дисковой подсистемы. При величине буфера в 10000 строк работа ускоряется в несколько раз.

Работу программы протестировал на 500 Мб файле. При величине буфера 10000-50000 скорость работы программы приемлемая.
Python:
import click
from tqdm import tqdm
import math
import codecs


def read_file(filename):
    try:
        with codecs.open(filename, 'r', encoding='utf-8', errors='ignore') as file:
            for line in file:
                yield line
    except IOError:
        print("Can't read from file, IO error")
        exit(1)


def write_file(filename, data):
    try:
        with codecs.open(filename, 'a', encoding='utf-8', errors='ignore') as file:
            file.writelines(data)
        return True
    except IOError:
        print("Can't write to output file, IO error")
        exit(1)


def get_files_names(lines_number, count_of_files):
    list_names = []
    current = 0
    for current in enumerate(count_of_files):
        file_name = str(current + 1) + '-' + str(current + lines_number)
        list_names.append(file_name)
        current = current + lines_number
    return list_names


def get_ranges_of_lines(lines, count_of_files, remain_lines):
    start_range = 1
    list_of_ranges = {}
    for current_file_number in range(count_of_files):
        if current_file_number <= count_of_files - 2:
            end_range = start_range + int(lines) - 1
        else:
            end_range = start_range + remain_lines
        lines_in_range = str(start_range) + '-' + str(end_range)
        list_of_ranges[lines_in_range] = {'start': start_range, 'end': end_range}
        start_range = start_range + int(lines)
    return list_of_ranges


def get_lines_cut_options(filename, lines):
    lines_in_file = lines_counter(filename)
    count_of_files = math.ceil(lines_in_file / int(lines))
    remain_lines = lines_in_file % int(lines)
    return lines_in_file, count_of_files, remain_lines


def lines_number_cut(filename, lines, buffer) -> None:
    lines_in_file, count_of_files, remain_lines = get_lines_cut_options(filename, lines)
    ranges_of_lines = get_ranges_of_lines(lines, count_of_files, remain_lines)
    print('File will cut on ' + str(count_of_files) + ' parts.')
    line_count = 1
    temp_1000_lines = []
    for line in tqdm(read_file(filename), ascii=True, dynamic_ncols=True, total=line_count, unit=" lines"):
            for key, value in ranges_of_lines.items():
                name = key + '.txt'
                start_line = value['start']
                end_line = value['end']
                if line_count >= start_line and line_count <= end_line:
                    temp_1000_lines.append(line)
                    if len(temp_1000_lines) > buffer:
                        write_file(name, temp_1000_lines)
                        del temp_1000_lines[:]
            line_count += 1


def lines_counter(filename):
    line_count = 0
    for line in tqdm(read_file(filename), ascii=True, dynamic_ncols=True, total=line_count, unit=" lines"):
        line_count += 1
    return line_count


@click.command()
@click.argument('filename')
@click.option(
    '--count', '-c', count=True,
    help='Count lines in file',
)
@click.option(
    '--lines', '-l',
    help='Lines number in cut parts of file',
)
@click.option(
    '--buffer', '-b', default=1000,
    help='Lines number in cut parts of file',
)
def main(filename, count, lines, buffer):
    """
    A file tool that that can:
    - count lines in file,
    - cut file on parts.
    """
    if filename is not None and count:
        print('Counting line in file:', filename)
        lines_count = lines_counter(filename)
        print(f"In file {filename} {lines_count} lines")
    if filename is not None and lines is not None and buffer is not None:
        lines_number_cut(filename, lines, buffer)


if __name__ == "__main__":
    main()
 

Вложения

  • filecut.zip
    3,8 КБ · Просмотры: 360

Usiein Osmanov

New member
06.03.2020
1
0
BIT
0
Недурное объяснение, но я почему-то не вижу в результате выполнения скрипта кучи текстовых документиков, на которые порезан входной файл.
Вопрос: ЧЯДНТ?
 
Мы в соцсетях:

Обучение наступательной кибербезопасности в игровой форме. Начать игру!