image

Предисловие



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

Чтобы не откладывать ознакомление с данным модулем просто наберите в командной строке:
pip install prod-cal


Гарантирую что проект будет работать на Python 2.7 и Windows 7, т. к. на этой конфигурации он разрабатывался.

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

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

Чтобы не плодить календарей в моём календаре можно использовать все методы стандартного модуля calendar.Calendar.


Состав проекта



После установки проект будет доступен в C:\Python27\Lib\site-packages\prodcal, если вы устанавливали пакет в виртуальное окружение, то ищите его в: <домашний каталог вирт. окружения>\Lib\site-packages\prodcal

Проект можно вообще не устанавливать а скачать его напрямую с сайта PyPi. После чего распаковать и использовать код непосредственно в своём проекте.

Проект состоит из следующих файлов (все с расширением *.py):
  • config — описывает информацию о поддерживаемых календарях и о календаре выбранном по умолчанию
  • service — файл со вспомогательными функциями, вроде приведения типов и т.п., некоторые функции из этого файла мы разберём ниже
  • holidays — файл содержит реализацию основного и пока единственного класса ProdCal
  • каталог prodcals — содержит наборы календарей и файл prod_dict, который содержит реализацию класса ProdDict (о нём также ниже)


Примеры использования
from procal import ProdCal

my_first_prod_cal = ProdCal()

# Проверяем праздничный день 1 мая
my_first_prod_cal.is_work_day(2016, 5, 1)

# Проверяем рабочий день
my_first_prod_cal.is_work_day(2016, 4, 1)

# Проверяем выходной день
my_first_prod_cal.is_work_day(2016, 4, 2)

# Проверяем перенос празничного дня (рабочий день)
my_first_prod_cal.is_work_day(2016, 2, 20)

# Передаём сразу объект даты
my_first_prod_cal.is_work_day(date(2016, 5, 1)

# Передаём в качестве аргумента строку (today - сегодня)
my_first_prod_cal.is_work_day('today')

# Передаём в качестве аргумента строку (yesterday - вчера)
my_first_prod_cal.is_work_day('yesterday')

# Передаём в качестве аргумента строку (tomorrow - завтра)
my_first_prod_cal.is_work_day('tomorrow')

# Проверяем количество рабочих дней в различных месяцах
my_first_prod_cal.count_work_days([2016, 4, 1], [2016, 4, 30])
my_first_prod_cal.count_work_days([2016, 5, 1], [2016, 5, 31])
my_first_prod_cal.count_work_days([2016, 6, 1], [2016, 6, 30])

# Передаём сразу в формате даты и времени
my_first_prod_cal.count_work_days(date(2016, 4, 1), date(2016, 4, 30))
my_first_prod_cal.count_work_days(date(2016, 5, 1), date(2016, 5, 31))
my_first_prod_cal.count_work_days(date(2016, 6, 1), date(2016, 6, 30))

# Передаём дату начала ввиде текста (today, yesterday, tomorrow)
my_first_prod_cal.count_work_days('today', date(2016, 4, 30))
my_first_prod_cal.count_work_days('yesterday', date(2016, 4, 30))
my_first_prod_cal.count_work_days('tomorrow', date(2016, 4, 30))

# Передаём в качестве конечной даты количество дней от даты начала (включительно)
my_first_prod_cal.count_work_days([2016, 4, 1], 30)
my_first_prod_cal.count_work_days('today', 30)

# Проверяем количество выходных дней в различных месяцах
my_first_prod_cal.count_holidays([2016, 4, 1], [2016, 4, 30])
my_first_prod_cal.count_holidays([2016, 5, 1], [2016, 5, 31])
my_first_prod_cal.count_holidays([2016, 6, 1], [2016, 6, 30])

# Передаём сразу в формате даты и времени
my_first_prod_cal.count_holidays(date(2016, 4, 1), date(2016, 4, 30))
my_first_prod_cal.count_holidays(date(2016, 5, 1), date(2016, 5, 31))
my_first_prod_cal.count_holidays(date(2016, 6, 1), date(2016, 6, 30))

# Передаём дату начала ввиде текста (today, yesterday, tomorrow)
my_first_prod_cal.count_holidays('today', date(2016, 4, 30))
my_first_prod_cal.count_holidays('yesterday', date(2016, 4, 30))
my_first_prod_cal.count_holidays('tomorrow', date(2016, 4, 30))

# Передаём в качестве конечной даты количество дней от даты начала (включительно)
my_first_prod_cal.count_holidays([2016, 4, 1], 30)
my_first_prod_cal.count_holidays('today', 30)

# Рассчитываем конечную дату по рабочим дням
my_first_prod_cal.get_date_by_work_days([2016, 4, 1], 21))
my_first_prod_cal.get_date_by_work_days('today', 21)




Реализация


Структура производственного календаря


Все производственные календари находятся в подкаталоге prodcals в виде отдельных файлов. Формат названия файла соотв. буквенному коду страны по ISO в нижнем регистре. Например, росс. производственный календарь находится в файле ru.py.

Файл содержит два словаря: NON_WORK_DAY_DICT и WORK_DAY_DICT, они имеют одинаковую структуру, первый словарь описывает нерабочие дни (праздничные), а второй описывает переносы рабочих дней на выходные. Словари не содержат указания на «стандартные» нерабочие дни субботу и воскресенье.
Календарь описывают два вложенных словаря: в год вкладываются месяцы, значением месяца является список дней.
Для удобства работы с календарём был сделан отдельный класс ProdDict (унаследован от стандартного словаря) в котором реализован метод is_value, который возвращает True или False в зависимости от наличия в словаре переданного значения. На вход данный класс принимает только даты. Реализация класса ProdDict описана в файле prod_dict (расположен в подкаталоге prodcals).

Реализация класса ProdCal


Данный класс может быть создан и без указания каких-либо аргументов, в этом случае будет использован календарь по умолчанию (российский). Если требуется указать какой календарь использовать, то необходимо передать именованный аргумент locale=<значение>, где значение — это код страны по ISO в любом регистре. Пример для создания производственного календаря Украины:
from prodcal import ProdCal
my_prod_cal = ProdCal(locale='UA')

В настоящий момент поддерживаются календари следующих стран: Беларусь, Грузия, Казахстан, Россия, Украина.

Методы класса ProdCal

is_work_day


Вход: дата, список (с int), кортеж аргументов, строка (поддерживает только: 'today', tomorrow', 'yesterday')
Выход: bool

Описание: проверяет заданную дату на предмет того рабочий ли сегодня день.

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

count_work_days, count_holidays


Вход: дата начала, дата окончания (периода), формат дат описан выше.
Выход: int

Описание: подсчитывает количество рабочих дней в заданном периоде (в случае count_work_days), а в случае count_holidays количество выходных дней.

get_date_by_work_days


Вход: дата начала, int
Выход: date

Описание: вычисляет конечную дату по заданному числу рабочих дней.

Описание сервисных функций


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

def get_date_today(day):
    today = datetime.today().date()
    if 'today' == day:
        return today
    elif 'yesterday' == day:
        return today - timedelta(days=1)
    elif 'tomorrow' == day:
        return today + timedelta(days=1)
    raise ValueError('Unknown string format', day)


Магия возможности использования дат в различных форматах (если так корректно выражаться) реализована в функции cast.
Реализация функции cast
def cast(start_date, end_date):
    if isinstance(start_date, (tuple, list)) and isinstance(end_date, (tuple, list)):
        start_date, end_date = date(*start_date), date(*end_date)

    if isinstance(start_date, str):
        start_date = get_date_today(start_date)
    elif isinstance(start_date, (tuple, list)):
        start_date = date(*start_date)

    if isinstance(end_date, (tuple, list)):
        end_date = date(*end_date)
    elif isinstance(end_date, int):
        end_date = calc_days_by_int(start_date, end_date)

    if isinstance(start_date, date) and isinstance(end_date, date):
        pass
    else:
        raise ValueError("Unknown format for parse")


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

Ещё интересным местом является функция get_prodcals, которая по переданному значению подгружает из подкаталога prodcals нужный календарь. Возможность этого обеспечивается с помощью функции import_module() из стандартной библиотеки importlib, которая интерпретирует переданную строку как путь к модулю. Например: import_module('prodcal.prodcals.ru') эквивалентно from prodcals import ru. Главный смысл использования этой функции в том, чтобы не указывать явно какие календари загружать, что несколько облегчает дальнейшую поддержку.

Поддержка новых календарей


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

Планы на развитие


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

Также планируется добавить ряд новых функций, например: расчёт даты и времени по переданным часам, написать тесты совместимости с Python3 и поправить некоторые ошибки.

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

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

Благодарность


Помимо меня в этом проекте участвует Аркадий Аристов из Челябинска, за что ему большое спасибо!

Комментарии (31)


  1. bromzh
    06.04.2016 18:38
    +6

    Сам код написан довольно весьма плохо:
    В каждом файле стоит shebang, зачем?.. А так:

    def get_prodcals(locale):
        pc = import_module('prodcal.prodcals.' + locale.lower())
        return pc.NON_WORK_DAY_DICT, pc.WORK_DAY_DICT

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


    1. balamut108
      06.04.2016 18:44
      -11

      Плохо писать и не указывать что плохо.

      На главной странице репозитория указано как принять участие.


      1. bromzh
        06.04.2016 19:42
        +12

        Так я написал что плохо. Могу подробнее:

        • shebang. Вы знаете, для чего он? Если да, то почему он в каждом файле?
        • использовать import_module — довольно плохая практика
        • path = self[day.year][day.month] — тоже плохо. Потому что ProdCal().is_work_day(2015, 2, 2) выдаст KeyError: 2015, а должен что-то другое
        • нет юникода: ProdCal().is_work_day(u'tomorrow') выдаст AttributeError: 'NoneType' object has no attribute 'year'
        • локаль надо по-умолчанию брать системную, а не хардкодить в конфиге.

        На главной странице репозитория указано как принять участие

        По приведённой ссылке я увидел вот что:
        для доступа в репозиторий требуется пройти собеседование в скайпе sidsupport (Владимир Сидоров) или получить доступ слушателя в рамках одно из курсов

        Т.е. мне нужно пройти собеседование, чтобы как-то помочь? Спасибо, не надо.
        Почему бы просто не завести репу в гитхабе? Там есть пулл-реквесты, issues, etc.


        1. balamut108
          06.04.2016 21:17
          -14

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


          1. veveve
            06.04.2016 23:22
            +9

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


          1. bromzh
            07.04.2016 04:40
            +3

            Как показывает практика, если бы человек действительно хотел бы развивать проект и вовлечь в него людей, то он бы выложил код в публичный репозиторий. А пока что я вижу в этом посте лишь попытку порекламировать ваш сайт с обучающими курсами, ведь ссылка в статье ведёт нас именно на ваш сайт. Может быть я не прав и ищу скрытый смысл там, где его нет.
            Компетенциями давайте не будем меряться. Я на питоне не писал уже давно, может подзабыл что-то. А вот от человека, который пишет на питоне 10 лет и учит ему за деньги других людей ожидаешь как минимум наличия юнит тестов и умения писать библиотеки под обе версии языка. Тем более, что с помощью six в таком некрупном проекте это делается тривиально.
            И да, я бы не участвовал в проекте: просто в ближайшем будущем такая библиотека мне точно не пригодятся. А пару issue я уже отписал, но пока только в комменте, так как в вашем репозитории на гитхабе пусто.
            К слову, если вы действительно намерены развивать проект: гитхаб же хорош не только сам по себе, но он ещё ценен сервисам с ним интегрирующимися. Например, есть сервис непрерывной интеграции, который может бесплатно запустить задачи для проекта, который хостится на гитхабе. Плюс, травис умеет интегрироваться с tox. Так что при наличии тестов можно было бы при каждом коммите (и пуше в гитхаб) сразу прогонять их на разных версиях языка и автоматически получать отчёт из трависа, что намного бы упростило дальнейшую разработку для всех участвующих.


            1. balamut108
              07.04.2016 04:55
              +1

              Я обновил ссылку на ГитХаб, идея как раз в развитии, а не в том чтобы сделать готовый продукт и представить его на суд читателей. За 6 дней проекта как мне кажется сделали не мало. До CI тоже руки дойдут. Ваши замечания учёл в течение получаса выложу их. Спасибо.


    1. bziker
      06.04.2016 21:15
      +2

      Я использую питончик время от времени в довольно простых скриптах, многого не знаю, поясни пожалуйста, почему это плохо?


  1. alexws54tk
    06.04.2016 18:50
    +2

    Поддержу предыдущего оратора по поводу более доступной платформы.

    Файл содержит два словаря: NON_WORK_DAY_DICT и WORK_DAY_DICT, они имеют одинаковую структуру, первый словарь описывает нерабочие дни (праздничные), а второй описывает переносы рабочих дней на выходные.

    Из какого источника берутся «дефолтные» сведения о праздниках и прочих „выходных“?


    1. balamut108
      06.04.2016 18:51
      -2

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


      1. alexws54tk
        06.04.2016 18:54
        +2

        Тоесть по дефолту надо самому «гуглить» праздники на конкретный год в конкретной стране?


        1. balamut108
          06.04.2016 21:15
          -3

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


          1. bromzh
            07.04.2016 04:08
            +2

            если бы был доступный способ получать актуальные данные смысла бы в данной библиотеке никакого не было.

            Когда у меня стояла кубунта 14.04 в ней был виджет календаря, в котором показывались выходные дни (включая праздники) и всякие невыходные праздники, типа дня эколога и др. Откуда-то они берут данные. Увы, в свежей версии (15.10) всё похерили, виджет перестал показывать такое.
            Но в целом, я за пару минут гугления нашёл производственный календарь с апи и вот такую штуку.
            Я бы сделал так: завёл отдельный репозиторий с данными по странам с их календарями. Достаточно написать скрипт, который бы при запуске качал json с данными или парсил бы сайт с продкалендарями, перегонял бы данные в нужный формат, и обновлял бы модуль в pypi. Версионирование сделать по типу pytz: первое число — год, второе — номер фикса. Добавилась новая страна — увеличиваем вторую цифру. Добавился календарь на следующий год — увеличиваем первую.
            В основном же пакете добавить зависимость от вышеописанного пакета с данными и оставить только логику по работе с датами. Таким образом, можно развивать сам календарь независимо от данных локали. И наоборот, фиксить данные календарей по странам можно не затрагивая пакет с логикой.


            1. nuklea
              07.04.2016 07:22

              Спасибо за basicdata.ru/api/calend. Давно искал что-то подобное для планирования расходов.


  1. igrishaev
    06.04.2016 20:27
    +4

    Почему репозиторий требует авторизации? Почему код не в гитхабе? Что-то сделали, а посмотреть нельзя.


    1. balamut108
      06.04.2016 21:14
      -8

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


      1. SirEdvin
        06.04.2016 21:17
        +1

        Как показывает, закрытая платформа отпугнет 90% людей, которые хотя бы глянули бы код. Если бы альтернатив не было бы, то это сработало бы, наверное, а так...


        1. balamut108
          06.04.2016 21:20
          -6

          Посмотреть сам код можно и на PyPi и после установки, этому ничто не мешает.

          Если бы Вы действительно хотели глянуть, то наверно бы глянули, а не тратили время подобные комментарии.


          1. SirEdvin
            06.04.2016 21:30
            +1

            С телефона без редактора? На PyPi только архив


            1. vsapronov
              07.04.2016 02:53

              Да, человек просто git не освоил и стесняется признаться.


          1. vsapronov
            07.04.2016 02:58

            Вы же понимаете, что:
            1. Доступ на запись к репозиторию вашего проекта в GitHub'е тоже не для всех.
            2. Доступ на чтение есть и на GitHub и через PyPi.
            3. Вы вообще пользовались GitHub'ом? Вы понимаете, что все патчи, которые вам не понравятся вы сможете не принимать?
            4. Если вы даете исходный код, то, наверное, имеет смысл сделать максимально удобным для всех способом. Какой это способ?


            1. balamut108
              07.04.2016 04:24
              +2

              Владимир, нет проблем, ссылку обновил, залил на ГитХаб.


      1. igrishaev
        06.04.2016 22:34
        +8

        Вы планируете построить сообщество вокруг модуля календаря?


  1. SirEdvin
    06.04.2016 21:15
    +1

    workdays? workcalendar? Чем они не подошли?


    1. balamut108
      06.04.2016 21:21
      -2

      Видимо Вы прочитали статью по диагонали и не поняли основной идеи этого модуля.


      1. SirEdvin
        06.04.2016 21:29

        Я не понял, почему в качестве ядра не был выбран один из этих моделей. Была ли на то причина?


        1. balamut108
          06.04.2016 21:43

          Потому что у clendar есть метод weekday(). В остальном смысла нет.


  1. sledopit
    07.04.2016 00:59
    +4

    Но ведь есть dateutil.
    Возможности dateutil гораздо шире. И сообщество вокруг проекта немаленькое (33 разработчика, если верить гитхабу).
    Там можно не только сформировать расписание выходных/рабочих дней (у вас всё равно это руками фактически задаётся, поэтому тут всё аналогично можно сделать), но и прикрутить туда рабочие часы и высчитать, например, +3 часа к текущему моменту с учётом рабочего времени и выходных.
    А также узнать ближайшую рабочую пятницу или первый понедельник следующего месяца и кучу других интересных вещей.


    1. balamut108
      07.04.2016 04:15

      Сегодня 6-й день существования проекта не судите строго, что касается других функций, то они будут добавлены по мере развития + в стандартном календаре это есть.


  1. vsapronov
    07.04.2016 03:06

    После прочтения комментов, понял, что надо дать людям код на GitHub. Если автор будет упираться дальше, то предлагаю этот «форк» запилить без него :)


    1. balamut108
      07.04.2016 05:04
      +1

      Ссылку уже обновил на ГитХаб.