Подробное руководство по созданию и публикации консольных приложений на 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 — это хранилище приложений для пакетов 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. Этот аккаунт нужен для тестирования пакетов перед публикацией их на рабочем сервере.
Загружать 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.
Перевод статьи Teiva Harsanyi : Solving Algorithmic Problems: Find a Duplicate in an Array
Комментарии