Блог 7even

про ruby, rails, sinatra, git и всё на свете

CLI-утилита на основе Thor

Для многих не секрет, что Ruby используется не только для web-разработки - этот язык также отлично пригодится для написания простеньких скриптов, упрощающих повседневные рутинные задачи. При этом самый естественный способ взаимодействия со скриптами - через командную строку, он же CLI (Command-Line Interface).

В стандартной библиотеке Ruby есть модуль OptionParser, здорово облегчающий работу с парсингом аргументов, пришедших из командной строки - но в этой статье речь пойдет о библиотеке thor, которую создал небезызвестный Yehuda Katz. Помимо обработки аргументов, thor предоставляет методы для более удобного взаимодействия с пользователем во время сессии работы самой утилиты.

thor заявлен как замена rake, и в этой стезе, надо сказать, он пока еще не преуспел - но зато гем здорово помогает при разработке собственных CLI-утилит, а также отлично справляется с генерацией файлов на основе шаблонов (именно на основе thor работают все генераторы в rails, начиная с версии 3.0).

Задача

Сначала я хотел написать очередную обзорную статью с небольшими примерами кода, но потом решил, что все-таки интереснее говорить о чем-то конкретном; поэтому по ходу статьи будем создавать простенькую CLI-утилитку, выводящую некоторую нужную информацию - непрочитанные письма в gmail-ящике, заголовки 5 последних новостей с ленты.ру и 5 последних коммитов в рельсы с гитхаба. А назовем ее workhorse (рабочая лошадка).

Для начала создадим скелет нашей утилиты. Структура будет стандартной - в bin/ лежат запускаемые скрипты (в нашем случае один), в lib/ библиотечные файлы, стандартный Gemfile и файл конфигурации config.yml в корне.

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

Для работы с gmail-ящиком потребуются логин и пароль. Чтобы не вводить их каждый раз, сложим их в config.yml в корне проекта, но позволим пользователю передавать их параметрами, если вдруг потребуется проверить другой ящик. Аналогично поступим и с гитхабом - в yaml-е напишем название репозитория rails/rails, но будем ждать параметр из командной строки.

(config.yml) download
1
2
3
4
5
6
gmail:
  mailbox:  'user@gmail.com'
  password: 'secret'

github:
  repo:     'rails/rails'

Gemfile

Во-первых нам нужен сам thor для создания command-line интерфейса; отвечающий за эту задачу класс будет наследоваться от класса Thor. Далее, для работы с GMail возьмем гем gmail; новости с ленты.ру будем брать через их RSS, а для парсинга HTML и XML данных я обычно использую nokogiri от tenderlove; ну а с Github API я выбрал библиотеку octokit (она не единственная в своем роде - но поскольку я столкнулся с задачей впервые, просто взял самый популярный по watcher-ам гем):

(Gemfile) download
1
2
3
4
5
6
7
8
9
# для CLI
gem 'thor'

# для сбора почты
gem 'gmail'
# для парсинга RSS-ки ленты.ру
gem 'nokogiri'
# для работы с Github API
gem 'octokit'

Основной функционал

Для такой простой задачи вполне хватит одного модуля, выполняющего всю работу - каждый метод будет производить какие-нибудь действия. Назовем этот класс Workhorse и положим в lib/workhorse.rb.

(workhorse.rb) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
require 'gmail'
require 'octokit'
require 'open-uri'
require 'nokogiri'

module Workhorse
  class << self
    # конфиг, считываемый из config.yml и кэшируемый в @config
    def config
      file_path = File.expand_path('../../config.yml', __FILE__)
      @config ||= YAML.load_file(file_path)
    end

    # получение писем из ящика на GMail
    def gmail(mailbox = nil, password = nil)
      # если адрес и пароль не переданы, они берутся из конфига
      mailbox   ||= config['gmail']['mailbox']
      password  ||= config['gmail']['password']

      begin
        # создаем соединение с GMail
        gmail = Gmail.connect(mailbox, password)

        if gmail.logged_in?
          # если авторизация прошла успешно, берем письма
          gmail.inbox.emails(:unread).map do |email|
            from = email.from.first
            {
              sender:   "#{from.mailbox}@#{from.host}",
              date:     Time.parse(email.date),
              subject:  Mail::Encodings.value_decode(email.subject)
            }
          end
        else
          # если авторизоваться не удалось, бросаем исключение
          raise ArgumentError, 'Email and/or password were incorrect.'
        end
      ensure
        # в обязательном порядке делаем логаут
        gmail.logout
      end
    end

    # получение 5 последних коммитов в определенный репозиторий на Github
    def github(repo = nil)
      # если название репозитория не передано, оно берется из конфига
      repo ||= config['github']['repo']

      Octokit.commits(repo).first(5).map do |commit|
        # fuck yeah
        commit    = commit.commit
        committer = commit.committer

        {
          committer:  committer.name,
          date:       Time.parse(committer.date),
          message:    commit.message.split("\n").first # нам достаточно заголовка сообщения
        }
      end
    end

    # получение заголовков 5 последних новостей с lenta.ru
    def lenta_ru
      # получаем XML-документ
      page = open('http://lenta.ru/rss/')
      # и отправляем его в nokogiri
      doc  = Nokogiri::XML(page)

      # каждая нода, найденная по //channel/item - отдельная новость
      doc.xpath('//channel/item').first(5).map do |item|
        {
          title:  item.xpath('.//title').first.content,
          date:   Time.parse(item.xpath('.//pubDate').first.content)
        }
      end
    end
  end
end

Каждый метод возвращает массив из хэшей:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Workhorse.gmail
# => [
#     [0] {
#          :sender => "ror2ru@googlegroups.com",
#            :date => 2012-02-19 04:52:09 +0400,
#         :subject => "Краткая сводка ror2ru@googlegroups.com - Сообщения: 11 в Темы: 4"
#     }
# ]
Workhorse.github
# => [
#     [0] {
#         :committer => "Vijay Dev",
#              :date => 2012-02-18 21:44:42 +0400,
#           :message => "Merge pull request #5085 from simi/patch-1"
#     },
#     [1] {
#         :committer => "Josef Šimánek",
#              :date => 2012-02-18 21:29:48 +0400,
#           :message => "Update activerecord/CHANGELOG.md"
#     },
#     ...
# ]
Workhorse.lenta_ru
# => [
#     [0] {
#         :title => "Из севшего на мель танкера \"Каракумнефть\" начали откачивать топливо",
#          :date => 2012-02-19 05:56:23 +0400
#     },
#     [1] {
#         :title => "Граждане Латвии отказались сделать русский язык государственным",
#          :date => 2012-02-19 01:56:14 +0400
#     },
#     ...
# ]

CLI

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

Для этого мы создаем свой класс, наследующийся от класса Thor, и создаем таски. Таск в thor представляет из себя обычный метод, перед которым указывается его описание с помощью метода desc (он принимает 2 параметра - название метода строкой или символом, и строку с описанием). В конце мы вызываем метод .start нашего класса.

(workhorse) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#!/usr/bin/env ruby

$LOAD_PATH << File.expand_path('../../lib', __FILE__)
require 'workhorse'
require 'thor'

class WorkhorseCLI < Thor
  # по умолчанию выполняется таск summary
  default_task :summary

  # описание таска - выводится и в общем списке тасков, и в хелпе по данному таску
  desc :gmail, 'Get unread emails from GMail'
  # строковые параметры mailbox и password с описанием
  method_option :mailbox,   type: :string, desc: 'A mailbox address to fetch from'
  method_option :password,  type: :string, desc: 'A password for that mailbox'
  # описания параметров выводятся в хелпе по таску
  def gmail
    puts
    # say - синтаксический сахар для puts с поддержкой разных цветов
    say 'Unread emails:', :green
    # опции, переданные в таск из командной строки, доступны в хэше options
    print_results Workhorse.gmail(options[:mailbox], options[:password])
  end

  desc :github, 'Get 5 latest commits from a repo on github'
  method_option :repo, type: :string, desc: 'A repository name'
  def github
    puts
    say 'Last commits:', :green
    print_results Workhorse.github(options[:repo])
  end

  desc :lenta, 'Get 5 latest news titles from lenta.ru'
  def lenta
    puts
    say 'Last 5 news on lenta.ru:', :green
    print_results Workhorse.lenta_ru
  end

  desc :summary, 'Get all info at once, options for other tasks can also be passed here'
  # в method_option также можно задавать алиасы, добавим ключ -q как синоним --quiet
  method_option :quiet, type: :boolean, aliases: '-q', desc: 'Do not ask any questions, output everything'
  def summary
    # метод yes? выводит пользователю вопрос, и возвращает
    # true при положительном ответе и, соответственно, false при отрицательном
    # (кстати, метод no? действует наоборот)
    if options[:quiet] || yes?('Want emails?')
      # метод invoke запускает указанный таск, при этом ему также будут доступны опции, переданные в текущий таск
      invoke :gmail
    end

    if options[:quiet] || yes?('Want commits?')
      invoke :github
    end

    if options[:quiet] || yes?('Want news?')
      invoke :lenta
    end
  end

private
  # вывод отформатированной таблицы в STDOUT
  def print_results(data)
    rows = data.map do |hash|
      row = []
      # выводим все даты в удобочитаемом формате
      row << hash.delete(:date).strftime('%d.%m.%Y %H:%M:%S')
      row + hash.values
    end

    # метод print_table принимает массив из массивов, и выводит его в виде отформатированной таблицы
    print_table rows
  end
end

# запускаем обработку входных параметров
WorkhorseCLI.start

Не забываем сделать файл исполняемым: chmod +x bin/workhorse

Смотрим, что у нас получилось. Для начала проверим страницы хелпа с информацией о наших тасках и их параметрах:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ bin/workhorse --help
Tasks:
  workhorse github       # Get 5 latest commits from a repo on github
  workhorse gmail        # Get unread emails from GMail
  workhorse help [TASK]  # Describe available tasks or one specific task
  workhorse lenta        # Get 5 latest news titles from lenta.ru
  workhorse summary      # Get all info at once

$ bin/workhorse help gmail
Usage:
  workhorse gmail

Options:
  [--mailbox=MAILBOX]    # A mailbox address to fetch from
  [--password=PASSWORD]  # A password for that mailbox

Get unread emails from GMail

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

1
2
3
4
5
6
7
8
9
10
11
12
$ bin/workhorse gmail

Unread emails:
19.02.2012 04:52:09  ror2ru@googlegroups.com  Краткая сводка ror2ru@googlegroups.com - Сообщения: 11 в Темы: 4
$ bin/workhorse github --repo=sinatra/sinatra

Last commits:
11.02.2012 01:11:12  Konstantin Haase  Merge pull request #464 from boucher/master
11.02.2012 01:06:51  Ross Boucher      Plus symbols in the URL should be converted to spaces when considered as param values.
02.02.2012 01:45:34  Konstantin Haase  Merge pull request #455 from ohhgabriel/readme-es
02.02.2012 01:44:29  Konstantin Haase  Merge pull request #461 from chanks/indifferent-params-in-arrays
02.02.2012 01:32:07  Chris Hanks       Params processing shouldn't error on arrays of objects other than hashes.

(здесь этого не видно, но заголовки ‘Unread emails:’ и ‘Last commits:’ подсвечиваются зеленым)

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
$ bin/workhorse help summary
Usage:
  workhorse summary

Options:
  -q, [--quiet]  # Do not ask any questions, output everything

Get all info at once, options for other tasks can also be passed here
$ bin/workhorse
Want emails? y

Unread emails:
19.02.2012 04:52:09  ror2ru@googlegroups.com  Краткая сводка ror2ru@googlegroups.com - Сообщения: 11 в Темы: 4
Want commits? y

Last commits:
18.02.2012 21:44:42  Vijay Dev       Merge pull request #5085 from simi/patch-1
18.02.2012 21:29:48  Josef Šimánek   Update activerecord/CHANGELOG.md
18.02.2012 20:32:34  Vijay Dev       Merge branch 'master' of github.com:lifo/docrails
18.02.2012 20:28:33  Vijay Dev       fix some typos [ci skip]
18.02.2012 15:58:52  Mike Gunderloy  Documenting the :inverse_of option for associations
Want news? y

Last 5 news on lenta.ru:
19.02.2012 05:56:25  Из севшего на мель танкера "Каракумнефть" начали откачивать топливо
19.02.2012 03:30:14  Виталий Кличко выиграл бой у Дерека Чисоры
19.02.2012 02:54:13  ГУВД Москвы сосчитало участников автопробега за Путина
19.02.2012 06:42:43  Пожар на севере Москвы потушен
19.02.2012 04:59:57  Бывший муж Уитни Хьюстон воздержался от скандала на панихиде

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ bin/workhorse --mailbox=my.email@gmail.com --password=secret --repo=7even/vkontakte_api
Want emails? y

Unread emails:
19.02.2012 04:52:09  ror2ru@googlegroups.com  Краткая сводка ror2ru@googlegroups.com - Сообщения: 11 в Темы: 4
Want commits? y

Last commits:
01.02.2012 01:53:51  Vsevolod Romashov  Travis CI integration
01.02.2012 01:45:29  Vsevolod Romashov  readme formatting
31.01.2012 07:07:32  Vsevolod Romashov  other links fixed
31.01.2012 07:02:11  Vsevolod Romashov  readme links fixed
31.01.2012 06:57:11  Vsevolod Romashov  release 0.1
Want news? y

Last 5 news on lenta.ru:
19.02.2012 05:56:25  Из севшего на мель танкера "Каракумнефть" начали откачивать топливо
19.02.2012 03:30:14  Виталий Кличко выиграл бой у Дерека Чисоры
19.02.2012 02:54:13  ГУВД Москвы сосчитало участников автопробега за Путина
19.02.2012 06:42:43  Пожар на севере Москвы потушен
19.02.2012 04:59:57  Бывший муж Уитни Хьюстон воздержался от скандала на панихиде

В заключение

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

Например, есть метод inject_into_class, вставляющий текст в файл сразу после определения класса (т.е. сразу после строки class MyClass); insert_into_file, вставляющий текст в файл после строки, найденной по переданному регулярному выражению; get, создающий файл по указанному пути с содержимым, полученным по указанному урлу итд.

Что еще почитать

Во-первых стоит почитать документацию thor:

  • Thor - работа с тасками и их опциями
  • Thor::Group - альтернативный вариант работы с тасками (здесь всегда выполняются все таски, и параметры не именуются при передаче скрипту; именно этот класс лежит в основе рельсовых генераторов)
  • Thor::Actions - в основном операции с файлами
  • Thor::Shell::Basic - взаимодействие с пользователем (тот самый CLI, о котором идет речь в статье)

Также немного информации есть в вики thor-а на github.

Ну и, конечно, не нужно бояться читать исходники :)

P.S.

Разумеется, получившаяся утилита лежит на гитхабе.

P.P.S.

В процессе написания статьи в качестве кейвордов было указано следующее:

1
keywords: [cli, thor]

Результат превзошел самые смелые ожидания:

1
<meta name="keywords" content="clithor">

Вывод: в octopress кейворды нужно задавать строкой, а не массивом.

Комментарии