Подробное руководство по созданию и публикации консольных приложений на Python

Консольные приложения — это те, которые вы запускаете в терминале. Скорее всего, вы уже пытались их создать. Или, по крайней мере, думали об их создании.

Но создание консольного приложения — это одно, а публикация его в репозиторий с открытым кодом (например, PyPI) — совсем другое. Хотя ни первое, ни второе не является чем-то запредельно трудным.

В этой статье я подробно расскажу, как можно создать простой CLI на Python и опубликовать его в PyPI.

Начало

Не так давно я занялся изучением уязвимостей open-source кода и понял, что хочу иметь в арсенале инструмент командной строки, который мог бы находить уязвимости напрямую из терминала. Уязвимости open-source кода обычно публикуются в открытых базах данных. Их можно найти на таких сайтах, как CVE, NVD, WhiteSource Vuln и т.д.

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

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

Для создания виртуальной среды можно воспользоваться командой python -m venv <path/name> (для Python 3) либо установить virtualenvwrapper с помощью pip install virtualenvwrapper и создать виртуальную среду virtualenv через mkvirtualenv -p /path/topython <path/name>.

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

mkvirtualenv -p /usr/bin/python cvecli-env
mkdir cvecli && cd cvecli
mkdir cver && touch setup.py && touch README.md && touch cver/__init__.py && touch .gitignore
pip install requests beautifulsoup4 lxml twine click
pip freeze > requirements.txt

Как только все успешно запустилось, откройте свой проект в любом редакторе кода. Вы увидите похожую структуру:

Создание веб-скрейпера

Для того, чтобы искать и просматривать уязвимости на сайте CVE, потребуется веб-скрейпер. Он поможет нам собирать информацию об уязвимостях. Мы создаем скрейпер в Requests и BeautifulSoup. Вот что будет делать наш скрейпер:

1. искать уязвимости;

2. получать информацию об уязвимости по ее названию на CVE.

Теперь откроем папку cver и создадим в ней файл под названием cve_scraper. Затем пропишем его базовые настройки:

import requests
from bs4 from BeautifulSoup

SEARCH_URL = "https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword="

CVE_URL = "https://cve.mitre.org/cgi-bin/cvename.cgi?name="

def get_html(url):
    request = requests.get(url)
    if request.status_code == 200:
        return request.content
    else:
        raise Exception("Bad request")


def search(s):
    pass

def lookup_cve(name):
    pass

Поиск уязвимостей

Для поиска уязвимостей на CVE используется URL в следующем формате: https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=<ключевое_слово>. Такой формат позволяет извлекать список уязвимостей, соответствующих ключевому слову.

Например, через URL можно получить список всех уязвимостей, связанных с Python:

Для извлечения данных открываем инструменты разработчика (Developer Console) и исследуем DOM-элемент с нужным представлением. Для этого кликните правой кнопкой по любому месту на странице и выберите “исследовать элемент” (inspect element) либо нажмите Ctrl + F12.

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

def search(s):    
    
    url = f"{SEARCH_URL}{s}"
    results=[]    
    html = get_html(url)
    soup = BeautifulSoup(html, "lxml")
    result_rows = soup.select("#TableWithRules table tr")

    for row in result_rows: 
        _row = {}    
        name = row.select_one("td a")
        description = row.select_one("td:nth-child(2)")

        if all([name, description]):

            _row["name"] = name.text
            _row["url"] = name.get("href")
            _row["description"] = description.text

            results.append(_row)
    
    return results

В коде выше мы:

1. отправляем запрос в SEARCH_URL с помощью Requests и получаем DOM-содержимое;

2. преобразуем DOM-содержимое в объекты BeautifulSoup. Это позволит нам выделять DOM-элементы с помощью CSS-селекторов, XPATH и других методов;

3. выделяем все tr под таблицей #TableWithRules. Выделяем первый столбец строки в качестве названия, а в качестве описания берем второй. Затем извлекаем текст.

Просмотр информации об уязвимостях

Чтобы просмотреть информацию об уязвимости, нужно взять ее CVE-ID и передать по этому адресу: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-ID.

Откройте инструменты разработчика и исследуйте DOM-структуру.

Такая структура чуть сложнее, поскольку в строках таблицы отсутствует ID или название класса. Поэтому нам нужно пройтись циклом по каждой строке и проверить, не является ли она подзаголовком. Если да, то следующий элемент берется в качестве содержимого-потомка. Каждый подзаголовок отображается в th, а его содержимое — в td.

def lookup_cve(name):
    url = f"{CVE_URL}{name}"
    html = get_html(url)
    soup = BeautifulSoup(html, "lxml")
    result_rows = soup.select("#GeneratedTable table tr")

subtitle = ""
    description = ""

raw_results = {}

for row in result_rows:
        head = row.select_one("th")
        if head:
           subtitle = head.text
        else:
            body = row.select_one("td")
            description = body.text.strip().strip("\n")
            raw_results[subtitle.lower()] = description   
            
    return raw_results

Готово! Мы успешно создали веб-скрейпер с CVE. Теперь добавим в него две функции (search и lookup_sve), которые будут искать уязвимости и получать информацию по ним через CVE-ID.

import requests
from bs4 import BeautifulSoup

SEARCH_URL = "https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword="
CVE_URL = "https://cve.mitre.org/cgi-bin/cvename.cgi?name="


def get_html(url):
    request = requests.get(url)
    if request.status_code == 200:
        return request.content
    else:
        raise Exception("Bad request")


def search(s):
    url = f"{SEARCH_URL}{s}"
    results=[]    
    html = get_html(url)
    soup = BeautifulSoup(html, "lxml")
    result_rows = soup.select("#TableWithRules table tr")


    for row in result_rows: 
        _row = {}    
        name = row.select_one("td a")
        description = row.select_one("td:nth-child(2)")
        if all([name, description]):

            _row["name"] = name.text
            _row["url"] = name.get("href")
            _row["description"] = description.text

            results.append(_row)

    return results


def lookup_cve(name):
    url = f"{CVE_URL}{name}"
    html = get_html(url)
    soup = BeautifulSoup(html, "lxml")
    result_rows = soup.select("#GeneratedTable table tr")

    subtitle = ""
    description = ""

    raw_results = {}

    for row in result_rows:
        head = row.select_one("th")
        if head:
           subtitle = head.text
        else:
            body = row.select_one("td")
            description = body.text.strip().strip("\n")
            raw_results[subtitle.lower()] = description   
            
    return raw_results

Создание консольного приложения

Наш следующий шаг — структурирование и создание консольного приложения через библиотеку Click.

Click — это Python-пакет для создания красивых интерфейсов командной строки с минимальным количеством кода и возможностью компоновки. Это один из лучших Python-пакетов для создания CLI, и с ним очень удобно работать.

Click позволяет создавать интерфейсы командной строки любого уровня — от самых простых до навороченных (например, Heroku).

В нашем CLI мы реализуем две команды:

1. поиск уязвимости;

2. просмотр уязвимости.

В папке cver создаем файл под названием __main__.py и прописываем его базовые настройки:

import sys
import click

@click.group()
@click.version_option("1.0.0")
def main():
    """A CVE Search and Lookup CLI"""
    print("Hye")
    pass

@main.command()
@click.argument('keyword', required=False)
def search(**kwargs):
    """Search through CVE Database for vulnerabilities"""
    click.echo(kwargs)
    pass

@main.command()
@click.argument('name', required=False)
def look_up(**kwargs):
    """Get vulnerability details using its CVE-ID on CVE Database"""
    click.echo(kwargs)
    pass

if __name__ == '__main__':
    args = sys.argv
    if "--help" in args or len(args) == 1:
        print("CVE")
    main()

Поиск уязвимостей

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

from scraper import search as cve_search, lookup_cve

@main.command()
@click.argument('keyword', required=False)
def search(**kwargs):
    """Search through CVE Database for vulnerabilities"""
    results = cve_search(kwargs.get("keyword"))
    for res in results:
        click.echo(f'{res["name"]} - {res["url"]} \n{res["description"]}')

Для запуска этой команды:

python cver/__main__.py search python

Просмотр уязвимости

Принцип тот же: используем lookup_cve из веб-скрейпера и передаем туда аргумент name из команды look_up.

@main.command()
@click.argument('name', required=False)
def look_up(**kwargs):
    """Get vulnerability details using its CVE-ID on CVE Database"""
    details = lookup_cve(kwargs.get("name"))
    click.echo(f'CVE-ID \n\n{details["cve-id"]}\n')
    click.echo(f'Description \n\n{details["description"]}\n')
    click.echo(f'References \n\n{details["references"]}\n')
    click.echo(f'Assigning CNA \n\n{details["assigning cna"]}\n')
    click.echo(f'Date Entry \n\n{details["date entry created"]}')

Для запуска этой команды:

python cver/__main__.py look-up CVE-2013–4238

Готово! Мы успешно создали инструмент командной строки по поиску с CVE.

Публикация консольного приложения на PyPI

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

PyPI — это хранилище приложений для пакетов Python. Там можно найти практически все пакеты, которые устанавливаются через pip. Для публикации пакета потребуется аккаунт на PyPI. Если он у вас уже есть, то смело читайте дальше.

Настройка пакета

Следующий шаг — это настройка Python-пакета с помощью setup.py. Если вы хотите, чтобы ваш пакет попал на PyPI, то нужно снабдить его базовым описанием. Эта информация указывается в файле setup.py.

Откройте setup.py в основной директории проекта и поместите в начало файла следующий код:

from setuptools import setup, find_packages
from io import open
from os import path

import pathlib
# Директория, в которой содержится этот файл
HERE = pathlib.Path(__file__).parent

# Текст README-файла
README = (HERE / "README.md").read_text()

# Автоматически собирает в requirements.txt все модули для install_requires, а также настраивает ссылки на зависимости
with open(path.join(HERE, 'requirements.txt'), encoding='utf-8') as f:
    all_reqs = f.read().split('\n')

install_requires = [x.strip() for x in all_reqs if ('git+' not in x) and (
    not x.startswith('#')) and (not x.startswith('-'))]
dependency_links = [x.strip().replace('git+', '') for x in all_reqs \
                    if 'git+' not in x]

В примере выше мы преобразовали содержимое файла README.md в одну строку для дальнейшего использования. Кроме того, мы перечислили все необходимые модули из requirements.txt и сгенерировали ссылки на их зависимости.

Ваш файл requirements.txt выглядит примерно так:

click
requests
beautifulsoup4
lxml
twine

Теперь давайте рассмотрим параметры настроек:

setup (
 name = 'cver',
 description = 'A simple commandline app for searching and looking up opensource vulnerabilities',
 version = '1.0.0',
 packages = find_packages(), # list of all packages
 install_requires = install_requires,
 python_requires='>=2.7', # any python greater than 2.7
 entry_points='''
        [console_scripts]
        cver=cver.__main__:main
    ''',
 author="Oyetoke Toby",
 keyword="cve, vuln, vulnerabilities, security, nvd",
 long_description=README,
 long_description_content_type="text/markdown",
 license='MIT',
 url='https://github.com/CITGuru/cver',

 download_url='https://github.com/CITGuru/cver/archive/1.0.0.tar.gz',
  dependency_links=dependency_links,
  author_email='[email protected]',
  classifiers=[
        "License :: OSI Approved :: MIT License",
        "Programming Language :: Python :: 2.7",
        "Programming Language :: Python :: 3",
        "Programming Language :: Python :: 3.7",
    ]
)

В коде выше мы добавили множество строк. Но подробнее обсудим только те из них, которые нужны для установки.

1. name — название пакета, которое появится на PyPI;

2. version — текущая версия пакета;

3. packages — пакеты и подпакеты с исходным кодом. В ходе установки мы пользуемся модулем find_packages. Он автоматически находит все подпакеты;

4. install_requires — используется для перечисления всех зависимостей или сторонних библиотек пакета. В cver мы пользуемся Requests, Beautifulsoup 4 и Click. Их нужно включить в требования к установке install_requires. Нам не нужно добавлять эту информацию вручную, поскольку она присутствует в requirements.txt;

5. entry_points — используется для создания скриптов, которые вызывают функцию внутри пакета. В данном случае мы создаем новый скрипт cver, который вызывает main() внутри файла cver/__main__.py. Наш основной элемент — это __main__.py, который вызывает функцию main() для запуска Click.

До того, как опубликовать пакет на PyPI или выложить в открытый доступ, необходимо снабдить его документацией. То, как будет выглядеть документация, целиком и полностью зависит от самого проекта. Это может быть как простой файл README.md, так и Readme.rst.

Пример хорошо оформленного README.md:

# CVER

A simple commandline app for searching and looking up opensource vulnerabilities

# Установка

## Через Pip

```bash
  $ pip install cver
```

## Вручную

```bash
  $ git clone https://github.com/citguru/cevr
  $ cd cver
  $ python setup.py install
```
# Использование

```bash
$ cver
```

## Поиск
`search <keyword>`

```bash
$ cver search python
```
## Просмотр

`search <name>`

```bash
$ cver look-up CVE-2020-2121
```

Кроме того, не забудьте создать файл .gitignore:

# Байтовая компиляция / оптимизация / DLL-файлы
__pycache__/
*.py[cod]
*$py.class

# C-расширения
*.so

# Сборка дистрибутива / пакета
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
# Обычно такие файлы пишутся Python-скриптом по шаблону
# до того, как PyInstaller создаст exe. Это нужно для добавления в файл даты и прочей информации.
*.manifest
*.spec

# Логи установщика
pip-log.txt
pip-delete-this-directory.txt

# Модульные тесты / отчеты по покрытию
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/

# Переводы
*.mo
*.pot

# Всякое на Django:
*.log
local_settings.py
db.sqlite3

# Всякое на Flask:
instance/
.webassets-cache

# Всякое на Scrapy:
.scrapy

# Sphinx-документация
docs/_build/

# PyBuilder
target/

# Jupyter Notebook
.ipynb_checkpoints

# pyenv
.python-version

# Schedule-файл Celery Beat 
celerybeat-schedule

# Проанализированные файлы SageMath
*.sage.py

# Окружения
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Настройки Spyder Project
.spyderproject
.spyproject

# Настройки Rope Project
.ropeproject

# Документация mkdocs
/site

# mypy
.mypy_cache/

Вот и все.

from setuptools import setup, find_packages
from io import open
from os import path

import pathlib
# Директория, в которой содержится этот файл
HERE = pathlib.Path(__file__).parent

# Текст README-файла
README = (HERE / "README.md").read_text()

# Автоматически собирает в requirements.txt все модули для install_requires
with open(path.join(HERE, 'requirements.txt'), encoding='utf-8') as f:
    all_reqs = f.read().split('\n')

install_requires = [x.strip() for x in all_reqs if ('git+' not in x) and (
    not x.startswith('#')) and (not x.startswith('-'))]
dependency_links = [x.strip().replace('git+', '') for x in all_reqs \
                    if 'git+' not in x]
setup (
 name = 'cver',
 description = 'A simple commandline app for searching and looking up opensource vulnerabilities',
 version = '1.0.0',
 packages = find_packages(), # list of all packages
 install_requires = install_requires,
 python_requires='>=2.7', # any python greater than 2.7
 entry_points='''
        [console_scripts]
        cver=cver.__main__:main
    ''',
 author="Oyetoke Toby",
 keyword="cve, vuln, vulnerabilities, security, nvd",
 long_description=README,
 long_description_content_type="text/markdown",
 license='MIT',
 url='https://github.com/CITGuru/cver',
 download_url='https://github.com/CITGuru/cver/archive/1.0.0.tar.gz',
  dependency_links=dependency_links,
  author_email='[email protected]',
  classifiers=[
        "License :: OSI Approved :: MIT License",
        "Programming Language :: Python :: 2.7",
        "Programming Language :: Python :: 3",
        "Programming Language :: Python :: 3.7",
    ]
)

Публикация на PyPI

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

Загружать Python-пакеты на PyPI мы будем с помощью специального инструмента — Twine. По идее, вы уже установили его на одном из предыдущих этапов. Если нет, то это можно сделать через pip install twine.

Создание и локальное тестирование пакета на тестовом сервере

Python-пакеты, опубликованные на PyPI, не распространяются в виде «голого» кода. Они оборачиваются в дистрибутивы. Самыми распространенными форматами дистрибутивов в Python являются Wheels и Source Archives.

Wheels — это zip-архив с кодом и готовыми расширениями. Source Archives содержит исходный код и вспомогательные файлы, упакованные в tar-архив.

Для локального тестирования пакета выполните:

python setup.py install

Теперь мы можем использовать его как:

cver search python

Для проверки пакета на тестовом сервере PyPI нужно сгенерировать сборку для локального тестирования. При создании этой сборки сгенерируются архивы Wheels и Source Archives.

Создание сборки:

python setup.py sdist bdist_wheel

Код ниже сгенерирует два файла в директории dist:

cvecli/
│
└── dist/
    ├── cver-1.0.0-py3-none-any.whl
    └── cver-1.0.0.tar.gz

Затем воспользуемся Twine. Теперь мы можем загрузить пакет на тестовый сервер PyPI:

twine upload — repository-url https://test.pypi.org/legacy/ dist/*

Затем у вас спросят логин и пароль.

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

Для установки из TestPyPI выполните следующую команду:

pip install -i https://test.pypi.org/simple/ cver==1.0.0

В тестовой среде вы можете проверить все команды и убедиться в их правильности перед публикацией пакета на рабочем сервере.

Протестировав все команды локально, переходите к публикации пакета на рабочем сервере:

twine upload dist/*

В процессе загрузки укажите свой логин и пароль. Вот и все!

Теперь пакет можно установить через:

pip install cver

Поздравляю! Ваш пакет был опубликован на PyPI. Просмотреть его можно здесь!

Заключение

В этой статье я пошагово объяснил процесс создания и публикации консольного приложения на Python.

Читайте также:


Перевод статьи Oyetoke Tobi Emmanuel: How to Build And Publish Command-Line Applications With Python

Предыдущая статьяP.S. Дорогой рефакторинг, нам нужно на время расстаться
Следующая статьяСвязный список в деталях