Привет, Хабр! Стоит ли говорить, что Python ОЧЕНЬ и ОЧЕНЬ популярный язык программирования, местами даже догоняя JavaScript. Python в мире программирования — это эсперанто, легкий язык созданный для всех, но его владельцам не мешало бы помыться.

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

Если вы ловили себя на мысли: «А почему мне бы не создать свою полноценную библиотеку?», то я рекомендую прочитать вам мою статью.

Эту статью вы можете использовать как шпаргалку для создания своих python-библиотек. Я полностью расскажу все этапы создания библиотеки: документация, тестирование, архитектура, публикация и управление зависимостями

Некоторые из вас могут подумать что мы изобретаем велосипед. А я в ответ скажу — сможете ли вы прямо сейчас, без подсказок, только по памяти, нарисовать велосипед без ошибок?


Итак, как обычно начинается создание проектов на python? Банально создание виртуального окружения

python3 -m venv venv
source venv/bin/activate

Но в этом проекте я решил отойти от такого способа, и использовать вместо этого систему правлению проектами Poetry. Poetry — это инструмент для управления зависимостями и сборкой пакетов в Python. А также при помощи Poetry очень легко опубликовать свою библиотеку на PyPi!

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

Все началось с того, что создателю Poetry Себастьену Юстасу потребовался единый инструмент для управления проектами от начала до конца, надежный и интуитивно понятный, который бы мог использоваться и в рамках сообщества. Одного лишь менеджера зависимостей было недостаточно, чтобы управлять запуском тестов, процессом развертывания и всем созависимым окружением. Этот функционал находится за гранью возможностей обычных пакетных менеджеров, таких как Pip или Conda. Так появился Python Poetry.

Установить poetry можно через pipx: pipx install poetry и через pip: pip install poetry --break-system-requirements. Это установит poetry глобально во всю систему.

Итак, давайте создадим проект при помощи poetry и установим зависимости:

poetry new <имя_проекта>
cd <имя_проекта>
poetry shell
poetry add asttokens executing colorama rich ruff loguru pygments

❯ Структура проекта

В этой статье я буду создавать библиотеку pycolor_palette-loguru — простой модуль для различных цветных сообшений и дебага.

Вы можете посмотреть репозиторий по ссылке.

Структуру проекта вы видите ниже:

.
├── docs/
├── example.py
├── poetry.lock
├── pycolor_palette_loguru
│   ├── __init__.py
│   ├── logger
│   │   ├── __init__.py
│   │   ├── logger.py
│   ├── paint.py
│   └── pygments_colorschemes.py
├── pyproject.toml
├── README.md
└── tests/

При использовании poetry, README.md, pyproject.toml, tests и директория вашей библиотеки (в моем случае pycolor_palette_loguru) будут созданы сами. poetry.lock — лок файл, также создается poetry.

Директория docs нужна для документации, tests для тестов.

Полная структура кода (из корня репозитория):

.
├── Doxyfile
├── LICENSE
├── pycolor-palette
│   ├── docs
│   │   └── ru
│   │       └── article.md
│   ├── example.py
│   ├── poetry.lock
│   ├── pycolor_palette_loguru
│   │   ├── __init__.py
│   │   ├── logger
│   │   │   ├── __init__.py
│   │   │   ├── logger.py
│   │   │   └── __pycache__
│   │   │       ├── __init__.cpython-312.pyc
│   │   │       └── logger.cpython-312.pyc
│   │   ├── paint.py
│   │   ├── __pycache__
│   │   │   ├── __init__.cpython-312.pyc
│   │   │   ├── paint.cpython-312.pyc
│   │   │   └── pygments_colorschemes.cpython-312.pyc
│   │   └── pygments_colorschemes.py
│   ├── pyproject.toml
│   ├── README.md
│   └── tests
│       └── __init__.py
└── README.md

Итак, начнем работу над проектом с документации.

❯ Создание документации при помощи Doxygen

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

Устанавливается Doxygen просто:

sudo pacman -S doxygen # Arch
sudo apt install doxygen # Ubuntu/Debian

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

Рассматриваемая система Doxygen как раз и выполняет эту задачу: она позволяет генерировать на основе исходного кода, содержащего комментарии специального вида, красивую и удобную документацию, содержащую в себе ссылки, диаграммы классов, вызовов и т.п. в различных форматах: HTML, LaTeX, CHM, RTF, PostScript, PDF, man-страницы.

В большинстве случаев Doxygen используется для документации программного обеспечения, написанного на языке C++, однако на самом деле данная система поддерживает гораздо большое число других языков: C, Objective-C, C#, PHP, Java, Python, IDL, Fortran, VHDL, Tcl, и частично D.

Итак, сначала нам нужно будет перейти в рабочую директорию и создать Doxyfile - файл конфигурации:

doxygen -g

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

Вот основные значения:

PROJECT_NAME           = "Project Name"             # Имя проекта
PROJECT_NUMBER         = 0.1.0                      # Версия проекта
PROJECT_BRIEF          = "Yet another project"      # Краткое описание проекта
OUTPUT_DIRECTORY       = docs                       # Куда складывать сгенерированную документацию
OUTPUT_LANGUAGE        = English                    # Язык документации
GENERATE_LATEX         = YES                        # Генерация LaTeX
INPUT                  = src include                # Директории, где искать файлы
RECURSIVE              = YES                        # Рекурсивный обход директорий
USE_MATHJAX            = YES                        # Использование mathjax (для latex в html)
  • PROJECT_NAME — название проекта.

  • PROJECT_NUMBER — версия проекта. Я придерживаюсь схемы "major.minor.patch".

  • PROJECT_BRIEF — краткое описание проекта.

  • OUTPUT_DIRECTORY — директория, куда будет записываться созданная документация.

  • OUTPUT_LANGUAGE — язык документации (доступные значения: Afrikaans, Arabic, Armenian, Brazilian, Bulgarian, Catalan, Chinese, Chinese-Traditional, Croatian, Czech, Danish, Dutch, English (United States), Esperanto, Farsi (Persian), Finnish, French, German, Greek, Hindi, Hungarian, Indonesian, Italian, Japanese, Japanese-en (Japanese with English messages), Korean, Korean-en (Korean with English messages), Latvian, Lithuanian, Macedonian, Norwegian, Persian (Farsi), Polish, Portuguese, Romanian, Russian, Serbian, Serbian-Cyrillic, Slovak, Slovene, Spanish, Swedish, Turkish, Ukrainian and Vietnamese).

  • GENERATE_LATEX — позволяет генерировать LaTeX.

  • INPUT — директории, откуда будет браться исходный код. Разделяются пробелами.

  • RECURSIVE — рекурсивный обход директорий.

  • USE_MATHJAX — для использования latex-формул в html.

Больше настроек вы можете посмотреть в этой статье.

Кастомизация

Дефолтный стиль, мягко говоря, некрасивый. Поэтому мы будем использовать кастомную css-тему:

HTML_STYLESHEET        = ./docs/doxygen-styles.css # путь до css стилей

Данный файл стилей вы можете скачать отсюда.

Посмотреть, что получилось у меня, вы можете по ссылке. А мой Doxyfile здесь.

Форма написания комментариев

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

Для того, чтобы doxygen правильно создал документацию, стоит следовать стилистике написания комментариев. Рассмотрим пример:

def debug_message(text: str, highlight: bool=False) -> str:
	"""
	print debug message

	:param      text:       The text
	:type       text:       str
	:param      highlight:  The highlight
	:type       highlight:  bool

	:returns:   message
	:rtype:     str
	"""
	prefix = f'{BG.blue}{FG.black}' if highlight else f'{FG.blue}'
	message = '%s%-*s | %-*s%s ::: %s%s' % (prefix, 20, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 
										20, 'DEBUG', Style.reset, text, Style.reset)
	print(message)
  • @brief — краткое описание.

  • @details — детали, подробное описание.

  • @todo — что-то нужно доделать. Doxygen генерирует отдельную страницу со списком всех @todo.

  • @warning — предупреждение.

  • @ref — ссылка на связанный класс или метод.

  • @param — передаваемый параметр, имеет направление ([in], [out], [in,out]).

  • @return — возвращаемое значение.

Также мы можем использовать latex-формулы: чтобы обозначить ее, надо в начале и в конце вставить \f$. Для создания latex-формул можно использовать онлайн редактор latex.

Также существуют следующие метки:

  • @authors — автор/ы;

  • @version — версия;

  • @date — дата;

  • @bug — известные ошибки;

  • @copyright — лицензия;

  • @example — файл примера работы;

  • @throws или @raise — исключение во время работы;

  • @mainpage Title — комментарий содержит текст для титульного листа документации;

  • @file fname — описание конкретного файла;

  • @deprecated — помечает класс или метод устаревшим. Как и с @todo, Doxygen генерирует отдельную страницу со списком всех устаревших классов и методов.

В действительности, Doxygen поддерживает куда больше команд. Например, он позволяет писать многостраничную @pagee) документацию с разделами @sectionn) и подразделами @subsectionn), указывать версии методов и классов @versionn), и не только.

Больше можно прочитать здесь.

Деплой документации на github-pages

Для начала создадим репозиторий на GitHub. Откройте главную вкладку репозитория.

Перейдите на вкладку Settings и откройте раздел Pages:

В этом разделе выберете Static HTML и нажмите кнопку Configure.

Откроется файл конфигурации задачи для CI/CD пайплайна. Все настройки хранятся в static.yaml файле. С помощью пайплайна можно вызывать системные команды. Вызов всех команд описывается с помощью шагов. Описание шагов начинается после строки steps. Путь к этой строке: jobs -> deploy -> steps.

Первые два шага по умолчанию выглядят так:

При обновлении GitHub Pages может что-то поменяться, но я не думаю, что будет сложно понять, где надо писать свои шаги.

Теперь требуется добавить шаги с установкой Doxygen. В качестве системы используется Ubuntu, а значит пакеты устанавливаются через apt.

# Install Doxygen
- name: Install Doxygen
  run: sudo apt install doxygen && doxygen --version
# Create documentation   
- name: Create documentation     
  run: doxygen

Документация создается, но надо ее развернуть. Для этого необходимо указать путь к папке с index.html в шаге Upload artifact. Путь к главной странице сайта: ./html/index.html. Тогда этот шаг будет выглядеть так:

Я же указал в конфигурации Doxyfile, что документация сохраняется в docs, поэтому я указываю путь ./docs/html/index.html.

На этом настройка закончена. Ссылка на документацию находится в раздел Settings -> Pages. То есть <username>.github.io/<reponame>.

P.S. инструкция взята с этой статье

❯ Тестирование

Не секрет, что разработчики создают программы, которые рано или поздно становятся очень масштабными (если смотреть на количество строчек кода). А с этим приходит и большая ответственность за качество.

В Python есть несколько библиотек для тестирования. В этой статье мы рассмотрим unittest и pytest.

Начнем с unittest, потому что именно с нее многие знакомятся с миром тестирования. Причина проста: библиотека по умолчанию встроена в стандартную библиотеку языка Python.

По формату написания тестов она сильно напоминает библиотеку JUnit, используемую в языке Java для написания тестов:

  • тесты должны быть написаны в классе;

  • класс должен быть наследован от базового класса unittest.TestCase;

  • имена всех функций, являющихся тестами, должны начинаться с ключевого слова test;

  • внутри функций должны быть вызовы операторов сравнения (assertX) — именно они будут проверять наши полученные значения на соответствие заявленным.

  • Является частью стандартной библиотеки языка Python: не нужно устанавливать ничего дополнительно;

  • Гибкая структура и условия запуска тестов. Для каждого теста можно назначить теги, в соответствии с которыми будем запускаться либо одна, либо другая группа тестов;

  • Быстрая генерация отчетов о проведенном тестировании, как в формате plaintext, так и в формате XML.

  • Для проведения тестирования придётся написать достаточно большое количество кода (по сравнению с другими библиотеками);

  • Из-за того, что разработчики вдохновлялись форматом библиотеки JUnit, названия основных функций написаны в стиле camelCase (например setUp и assertEqual). В языке python согласно рекомендациям pep8 должен использоваться формат названий snake_case (например set_up и assert_equal).

Давайте я покажу код:

from math import sqrt
import unittest


def square_it_up(num: float) -> float:
	"""
	square num

	:param      num:  The number
	:type       num:  float

	:returns:   value
	:rtype:     float
	"""
	return num ** 2



def square_eq_solver(a, b, c):
	"""
	решение квадратных уравнений

	:param      a:    a
	:type       a:    int/float
	:param      b:    b
	:type       b:    int/float
	:param      c:    c
	:type       c:    int/float

	:returns:   roots
	:rtype:     list
	"""
	result = []
	discriminant = b * b - 4 * a * c

	if discriminant == 0:
		result.append(-b / (2 * a))
	elif discriminant > 0:
		result.append((-b + sqrt(discriminant)) / (2 * a))
		result.append((-b - sqrt(discriminant)) / (2 * a))

	return result


class BasicTestCase(unittest.TestCase):
	def test_square_it_up(self):
		res = square_it_up(10)
		res2 = square_it_up(2)
		self.assertEqual(res, 100)
		self.assertEqual(res2, 4)


class SquareEqSolverTestCase(unittest.TestCase):
	def test_no_root(self):
		res = square_eq_solver(10, 0, 2)
		self.assertEqual(len(res), 0)

	def test_single_root(self):
		res = square_eq_solver(10, 0, 0)
		self.assertEqual(len(res), 1)
		self.assertEqual(res, [0])

	def test_multiple_root(self):
		res = square_eq_solver(2, 5, -3)
		self.assertEqual(len(res), 2)
		self.assertEqual(res, [0.5, -3])

Мы создали функции для возведения в квадрат и решения квадратного уравнения. Они как раз и будут тестироваться

Мы создаем классы Test Cases, которые наследуются от unittest.TestCase. Мы берем какое-либо значение с заданными параметрами и проверяем, совпадает ли оно с требованиями через функцию assertEqual().

Давайте запустим тесты через команду:

python3 -m unittest tests/unittest_example.py

....
----------------------------------------------------------------------
Ran 4 tests in 0.005s

OK

Отлично! Все получилось. Не будем задерживаться, перейдем к pytest. А документация по unittest доступна по ссылке.


Pytest позволяет провести модульное тестирование (тестирование отдельных компонентов программы), функциональное тестирование (тестирование способности кода удовлетворять бизнес-требования), тестирование API (application programming interface) и многое другое.

Установить его при помощи poetry просто:

poetry add pytest
poetry install # если нужна установка

И его намного проще использовать чем unittest.

Рассмотрим наш прошлый пример, но уже с применением pytest:

from math import sqrt


def square_it_up(num: float) -> float:
	"""
	square num

	:param      num:  The number
	:type       num:  float

	:returns:   value
	:rtype:     float
	"""
	return num ** 2



def square_eq_solver(a, b, c):
	"""
	решение квадратных уравнений

	:param      a:    a
	:type       a:    int/float
	:param      b:    b
	:type       b:    int/float
	:param      c:    c
	:type       c:    int/float

	:returns:   roots
	:rtype:     list
	"""
	result = []
	discriminant = b * b - 4 * a * c

	if discriminant == 0:
		result.append(-b / (2 * a))
	elif discriminant > 0:
		result.append((-b + sqrt(discriminant)) / (2 * a))
		result.append((-b - sqrt(discriminant)) / (2 * a))

	return result


def test_square_it_up():
	assert square_it_up(10) == 100


def test_no_root():
	assert len(square_eq_solver(10, 0, 2)) == 0


def test_single_root():
	assert len(square_eq_solver(10, 0, 0)) == 1


def test_multiple_root():
	assert square_eq_solver(2, 5, -3) == [0.5, -3]

Мы просто используем ключевое слово assert.

Запуск тестов также как и у unittest:

pytest tests/pytest_example.py

============================================================================= test session starts ==============================================================================
platform linux -- Python 3.12.6, pytest-8.3.3, pluggy-1.5.0
rootdir: /home/alexeev/Desktop/Projects/pycolor-palette/pycolor-palette
configfile: pyproject.toml
collected 4 items

tests/pytest_example.py ....                                                                                                                                             [100%]

============================================================================== 4 passed in 0.13s ===============================================================================

Вот и все, что я хотел сказать о тестировании. Быстро, но базу рассказал. Документация по pytest доступна по ссылке.

❯ Пишем код

Как я уже говорил, моя библиотека-пример — это будет небольшой модуль для дебага и логгирования. Я брал icecream source code и использую loguru для логгинга.

В итоге, у нас получится такая библиотека (ниже пример кода):

#!venv/bin/python3
"""pycolor_palette Example File.

Copyright Alexeev Bronislav (C) 2024
"""
from loguru import logger
from pycolor_palette_loguru.logger import PyDBG_Obj, benchmark, set_default_theme, debug_func, setup_logger
from pycolor_palette_loguru.paint import info_message, warn_message, error_message, other_message, FG, Style, debug_message, run_exception
from pycolor_palette_loguru.pygments_colorschemes import CatppuccinMocha

set_default_theme(CatppuccinMocha)
pydbg_obj = PyDBG_Obj()
setup_logger('DEBUG')


@benchmark
@debug_func
def debug_print() -> list:
  num = 12
  float_int = 12.12
  string = 'Hello'
  boolean = True
  list_array = [1, 2, 3, 'Hi', True, 12.2]
  dictionary = {1: "HELLO", 2: "WORLD"}

  pydbg_obj(num, float_int, string, boolean, list_array, dictionary)


debug_print()
logger.debug("This is debug!")
logger.info("This is info!")
logger.warning("This is warning!")
logger.error("This is error!")

# Simple messages
info_message('INFORMATION')
warn_message('WARNING')
error_message('EXCEPTION')
debug_message('DEBUG')
other_message('SOME TEXT', 'OTHER')
# Highlight bg
info_message('Highlight INFORMATION', True)
warn_message('Highlight WARNING', True)
error_message('Highlight EXCEPTION', True)
debug_message('Highlight DEBUG', True)
other_message('Highlight SOME TEXT', 'OTHER', True)

print(f'{FG.red}{Style.bold}BOLD RED{Style.reset}{Style.dim} example{Style.reset}')

run_exception('EXCEPTION')

А также вы ее можете сами установить через pip:

pip3 install pycolor_palette-loguru

И вы также сможете сделать! Так что не буду медлить, начнем творить!

Цветные цвета

Итак, начнем с самого базового файла в нашей библиотеки — файле paint.py, которые отвечает за форматирование и цвет в терминале.

Вот сам код:

#!/usr/bin/python3
from datetime import datetime
from sys import stdout, stdin
from time import sleep
import os


def cls():
	"""
	Clear screen (unix).
	"""
	os.system('clear')


class FG:
	"""
	Foreground class.
	"""

	black = "\u001b[30m"
	red = "\u001b[31m"
	green = "\u001b[32m"
	yellow = "\u001b[33m"
	blue = "\u001b[34m"
	magenta = "\u001b[35m"
	cyan = "\u001b[36m"
	white = "\u001b[37m"

	@staticmethod
	def rgb(r: int, g: int, b: int) -> str:
		"""
		Function for convert rgb to ansi color code.
		
		:param      r:    red color
		:type       r:    int
		:param      g:    green color
		:type       g:    int
		:param      b:    blue color
		:type       b:    int
		
		:returns:   color
		:rtype:     str
		"""
		return f"\u001b[38;2;{r};{g};{b}m"


class BG:
	"""
	Background class.
	"""

	black = "\u001b[40m"
	red = "\u001b[41m"
	green = "\u001b[42m"
	yellow = "\u001b[43m"
	blue = "\u001b[44m"
	magenta = "\u001b[45m"
	cyan = "\u001b[46m"
	white = "\u001b[47m"

	@staticmethod
	def rgb(r: int, g: int, b: int) -> str:
		"""
		Function for convert rgb to ansi color code.
		
		:param      r:    red color
		:type       r:    int
		:param      g:    green color
		:type       g:    int
		:param      b:    blue color
		:type       b:    int
		
		:returns:   color
		:rtype:     str
		"""
		return f"\u001b[48;2;{r};{g};{b}m"


class Style:
	"""
	Style class.
	"""

	reset = "\u001b[0m"
	bold = "\u001b[1m"
	dim = "\u001b[2m"
	italic = "\u001b[3m"
	underline = "\u001b[4m"
	reverse = "\u001b[7m"
	clear = "\u001b[2J"
	clearline = "\u001b[2K"
	up = "\u001b[1A"
	down = "\u001b[1B"
	right = "\u001b[1C"
	left = "\u001b[1D"
	nextline = "\u001b[1E"
	prevline = "\u001b[1F"
	top = "\u001b[0;0H"
	
	@staticmethod
	def to(x, y):
		"""
		Move cursor to x, y.
		
		:param      x:    x
		:type       x:    int
		:param      y:    y
		:type       y:    int
		
		:returns:   cursor
		:rtype:     string
		"""
		return f"\u001b[{y};{x}H"
	
	@staticmethod
	def write(text="\n"):
		"""
		Print to stdout.
		
		:param      text:  The text
		:type       text:  str
		"""
		stdout.write(text)
		stdout.flush()
	
	@staticmethod
	def writew(text="\n", wait=0.01):
		"""
		Print (typewrite effect).
		
		:param      text:  The text
		:type       text:  str
		:param      wait:  The wait
		:type       wait:  float
		"""
		for char in text:
			stdout.write(char)
			stdout.flush()
			sleep(wait)
  	
	@staticmethod
	def read(begin=""):
		"""
		Read input from keyboard.
		
		:param      begin:  The begin
		:type       begin:  str
		"""
		text = ""
		stdout.write(begin)
		stdout.flush()
		while True:
			char = ord(stdin.read(1))
      
		if char == 3: return
		elif char in (10, 13): return text
		else: text += chr(char)
	
	@staticmethod
	def readw(begin="", wait=0.5):
		"""
		Read input with wait.
		
		:param      begin:  The begin
		:type       begin:  str
		:param      wait:   The wait
		:type       wait:   float
		"""
		text = ""

		for char in begin:
			stdout.write(char)
			stdout.flush()
			sleep(wait)
		
		while True:
			char = ord(stdin.read(1))
      
			if char == 3: return
			elif char in (10, 13): return text
			else: text += chr(char)


def info_message(text: str, highlight: bool=False) -> str:
	"""
	print info message

	:param      text:       The text
	:type       text:       str
	:param      highlight:  The highlight
	:type       highlight:  bool

	:returns:   message
	:rtype:     str
	"""
	prefix = f'{BG.green}{FG.black}' if highlight else f'{FG.green}'
	message = '%s%-*s | %-*s%s ::: %s%s' % (prefix, 20, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 
										20, 'INFO', Style.reset, text, Style.reset)
	print(message)


def warn_message(text: str, highlight: bool=False) -> str:
	"""
	print warn message

	:param      text:       The text
	:type       text:       str
	:param      highlight:  The highlight
	:type       highlight:  bool

	:returns:   message
	:rtype:     str
	"""
	prefix = f'{BG.yellow}{FG.black}' if highlight else f'{FG.yellow}'
	message = '%s%-*s | %-*s%s ::: %s%s' % (prefix, 20, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 
										20, 'WARNING', Style.reset, text, Style.reset)
	print(message)


def error_message(text: str, highlight: bool=False) -> str:
	"""
	print error message

	:param      text:       The text
	:type       text:       str
	:param      highlight:  The highlight
	:type       highlight:  bool

	:returns:   message
	:rtype:     str
	"""
	prefix = f'{BG.red}{FG.black}' if highlight else f'{FG.red}'
	message = '%s%-*s | %-*s%s ::: %s%s' % (prefix, 20, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 
										20, 'ERROR', Style.reset, text, Style.reset)
	print(message)


def debug_message(text: str, highlight: bool=False) -> str:
	"""
	print debug message

	:param      text:       The text
	:type       text:       str
	:param      highlight:  The highlight
	:type       highlight:  bool

	:returns:   message
	:rtype:     str
	"""
	prefix = f'{BG.blue}{FG.black}' if highlight else f'{FG.blue}'
	message = '%s%-*s | %-*s%s ::: %s%s' % (prefix, 20, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 
										20, 'DEBUG', Style.reset, text, Style.reset)
	print(message)


def other_message(text: str, msg_type: str, highlight: bool=False) -> str:
	"""
	print message

	:param      text:       The text
	:type       text:       str
	:param      highlight:  The highlight
	:type       highlight:  bool

	:returns:   message
	:rtype:     str
	"""
	prefix = f'{BG.magenta}{FG.black}' if highlight else f'{FG.magenta}'
	message = '%s%-*s | %-*s%s ::: %s%s' % (prefix, 20, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 
										20, msg_type, Style.reset, text, Style.reset)
	print(message)


def run_exception(text: str, highlight: bool=False):
	"""
	print and raise exception

	:param      text:       The text
	:type       text:       str
	:param      highlight:  The highlight
	:type       highlight:  bool

	:returns:   message
	:rtype:     str
	"""
	prefix = f'{BG.red}{FG.black}' if highlight else f'{FG.red}'
	message = '%s%s%-*s | %-*s ::: %s%s' % (Style.bold, prefix, 20, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 
									20, "EXCEPTION", text, Style.reset)
	print(message)
	raise Exception(text)

Классы FG и BG отображают цвет текста и цвет фона (с возможностью передать цвет через RGB), а вот Style поинтереснее — форматирование, чтение ввода и управление курсорам. Также мы имеем функции для вывода красивых дебаг-сообщений:

2024-10-01 00:38:42  | INFO                 ::: INFORMATION
2024-10-01 00:38:42  | WARNING              ::: WARNING
2024-10-01 00:38:42  | ERROR                ::: EXCEPTION
2024-10-01 00:38:42  | DEBUG                ::: DEBUG
2024-10-01 00:38:42  | OTHER                ::: SOME TEXT

Мы используем форматирование через % для выравнивания текста. В начале дата, потом тип сообщения, а в конце текст сообщения. А run_exception вызывает исключение:

2024-10-01 00:38:42  | EXCEPTION            ::: EXCEPTION
Traceback (most recent call last):
  File "/home/alexeev/Desktop/Projects/pycolor-palette/pycolor-palette/example.py", line 50, in <module>
    run_exception('EXCEPTION')
  File "/home/alexeev/Desktop/Projects/pycolor-palette/pycolor-palette/pycolor_palette_loguru/paint.py", line 290, in run_exception
    raise Exception(text)
Exception: EXCEPTION

Итак, начнем писать код цветовых схем для будущего класса PyDBG_OBJ (вдохновлен библиотекой icecream) — этот класс нужен для более красивого и понятного дебага:

pydbg_obj | num: 12
            float_int: 12.12
            string: 'Hello'
            boolean: True
            list_array: [1, 2, 3, 'Hi', True, 12.2]
            dictionary: {1: 'HELLO', 2: 'WORLD'}

Но он также имеет подсветку синтаксиса. Для этого я использую pygments, и в коде я реализовал три цветовых схемы — Solarized, Catppuccin и Gruvbox.

Сначала импортируем токены и класс стиля

from pygments.style import Style
from pygments.token import (
	Text, Name, Error, Other, String, Number, Keyword, Generic, Literal,
	Comment, Operator, Whitespace, Punctuation)

После создадим класс, наследуемый от Style:

class SolarizedDark(Style):
	"""
	This class describes a solarized dark colorscheme.
	"""
	BASE03  = '#002b36' # noqa
	BASE02  = '#073642' # noqa
	BASE01  = '#586e75' # noqa
	BASE00  = '#657b83' # noqa
	BASE0   = '#839496' # noqa
	BASE1   = '#93a1a1' # noqa
	BASE2   = '#eee8d5' # noqa
	BASE3   = '#fdf6e3' # noqa
	YELLOW  = '#b58900' # noqa
	ORANGE  = '#cb4b16' # noqa
	RED     = '#dc322f' # noqa
	MAGENTA = '#d33682' # noqa
	VIOLET  = '#6c71c4' # noqa
	BLUE    = '#268bd2' # noqa
	CYAN    = '#2aa198' # noqa
	GREEN   = '#859900' # noqa

	styles = {
		Text:                   BASE0,
		Whitespace:             BASE03,
		Error:                  RED,
		Other:                  BASE0,

		Name:                   BASE1,
		Name.Attribute:         BASE0,
		Name.Builtin:           BLUE,
		Name.Builtin.Pseudo:    BLUE,
		Name.Class:             BLUE,
		Name.Constant:          YELLOW,
		Name.Decorator:         ORANGE,
		Name.Entity:            ORANGE,
		Name.Exception:         ORANGE,
		Name.Function:          BLUE,
		Name.Property:          BLUE,
		Name.Label:             BASE0,
		Name.Namespace:         YELLOW,
		Name.Other:             BASE0,
		Name.Tag:               GREEN,
		Name.Variable:          ORANGE,
		Name.Variable.Class:    BLUE,
		Name.Variable.Global:   BLUE,
		Name.Variable.Instance: BLUE,

		String:                 CYAN,
		String.Backtick:        CYAN,
		String.Char:            CYAN,
		String.Doc:             CYAN,
		String.Double:          CYAN,
		String.Escape:          ORANGE,
		String.Heredoc:         CYAN,
		String.Interpol:        ORANGE,
		String.Other:           CYAN,
		String.Regex:           CYAN,
		String.Single:          CYAN,
		String.Symbol:          CYAN,

		Number:                 CYAN,
		Number.Float:           CYAN,
		Number.Hex:             CYAN,
		Number.Integer:         CYAN,
		Number.Integer.Long:    CYAN,
		Number.Oct:             CYAN,

		Keyword:                GREEN,
		Keyword.Constant:       GREEN,
		Keyword.Declaration:    GREEN,
		Keyword.Namespace:      ORANGE,
		Keyword.Pseudo:         ORANGE,
		Keyword.Reserved:       GREEN,
		Keyword.Type:           GREEN,

		Generic:                BASE0,
		Generic.Deleted:        BASE0,
		Generic.Emph:           BASE0,
		Generic.Error:          BASE0,
		Generic.Heading:        BASE0,
		Generic.Inserted:       BASE0,
		Generic.Output:         BASE0,
		Generic.Prompt:         BASE0,
		Generic.Strong:         BASE0,
		Generic.Subheading:     BASE0,
		Generic.Traceback:      BASE0,

		Literal:                BASE0,
		Literal.Date:           BASE0,

		Comment:                BASE01,
		Comment.Multiline:      BASE01,
		Comment.Preproc:        BASE01,
		Comment.Single:         BASE01,
		Comment.Special:        BASE01,

		Operator:               BASE0,
		Operator.Word:          GREEN,

		Punctuation:            BASE0,
	}

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

Перейдем к самому главному — директории logger/. Создадим в ней сразу __init__.py:

from pycolor_palette_loguru.logger.logger import PyDBG_Obj, set_default_theme, benchmark, debug_func, setup_logger

__all__ = (set_default_theme, PyDBG_Obj, debug_func, benchmark, setup_logger)

И создадим файл logger.py. Именно в этом файле и будет центральный функционал.

Сначала нужно импортировать все нужные модули и библиотеки:

from time import time
import ast
import inspect
import pprint
import sys
import warnings
from datetime import datetime
import functools
from contextlib import contextmanager
from os.path import basename, realpath
from textwrap import dedent
import colorama
import executing
from pygments import highlight
from pygments.formatters import Terminal256Formatter
from pygments.lexers import PythonLexer as PyLexer, Python3Lexer as Py3Lexer
from typing import Union, List
import logging
from loguru import logger

# ❯ Написанные модули
from pycolor_palette_loguru.paint import debug_message
from pycolor_palette_loguru.pygments_colorschemes import *

Создадим несколько переменных и функцию для смены темы:

PYTHON2 = (sys.version_info[0] == 2)

_absent = object()
default_theme = Terminal256Formatter(style=CatppuccinMocha)


def set_default_theme(theme):
	global default_theme
	default_theme = Terminal256Formatter(style=theme)

Функция set_default_theme позволяет задать новую тему подсветки кода.

Создадим класс и функцию, отвечающие за конфигурацию логгера loguru:

class InterceptHandler(logging.Handler):
	"""
	This class describes an intercept handler.
	"""

	def emit(self, record) -> None:
		"""
		Get corresponding Loguru level if it exists

		:param      record:  The record
		:type       record:  record

		:returns:   None
		:rtype:     None
		"""
		try:
			level = logger.level(record.levelname).name
		except ValueError:
			level = record.levelno

		frame, depth = logging.currentframe(), 2

		while frame.f_code.co_filename == logging.__file__:
			frame = frame.f_back
			depth += 1

		logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())


def setup_logger(level: Union[str, int] = 'DEBUG', ignored: List[str] = "") -> None:
	"""
	Setup logger

	:param      level:    The level
	:type       level:    str
	:param      ignored:  The ignored
	:type       ignored:  List[str]
	"""
	logging.basicConfig(
		handlers=[InterceptHandler()],
		level=logging.getLevelName(level)
	)

	for ignore in ignored:
		logger.disable(ignore)

	logger.info('Logging is successfully configured')

Функция setup_logger настраивает логгер. Кстати, loguru пользоваться очень просто:

from loguru import logger

logger.info("info message")

Это будет независимо от файла, и будет распространяться на всю сессию.

Напишем некоторые базовые функции, которые пригодятся при выводе сообщений:

@contextmanager
def supportTerminalColorsInWindows():
	"""
	Support terminal colors in Windows OS with colorama.
	"""
	colorama.init()
	yield
	colorama.deinit()


def stderrPrint(*args):
	"""
	Print to stderr.
	
	:param      args:  The arguments
	:type       args:  list
	"""
	print(*args)


def isLiteral(s):
	"""
	Check string if literal.
	
	:param      s:    string
	:type       s:    str
	
	:returns:   True if the specified s is literal, False otherwise.
	:rtype:     bool
	"""
	try:
		ast.literal_eval(s)
	except Exception:
		return False
	return True


def bindStaticVariable(name, value):
	def decorator(fn):
		"""
		Wrapper

		:param      fn:   The function
		:type       fn:   Function
		"""
		setattr(fn, name, value)
		return fn
	return decorator


@bindStaticVariable(
	'lexer', PyLexer(ensurenl=False) if PYTHON2 else Py3Lexer(ensurenl=False))
def colorize(s):
	"""
	Colorize with pygments.
	
	:param      s:    string
	:type       s:    str
	
	:returns:   highlighted
	:rtype:     str
	"""
	self = colorize
	return highlight(s, self.lexer, default_theme)

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

DEFAULT_PREFIX = 'pydbg_obj | '
DEFAULT_LINE_WRAP_WIDTH = 80  # Characters.
DEFAULT_CONTEXT_DELIMITER = '~ '
DEFAULT_OUTPUT_FUNCTION = colorized_stderr_print
DEFAULT_ARG_TO_STRING_FUNCTION = pprint.pformat


NO_SOURCE_AVAILABLE_WARNING_MESSAGE = (
	'Failed to access the underlying source code for analysis. Was PyDBG_Obj() '
	'invoked in a REPL (e.g. from the command line), a frozen application '
	'(e.g. packaged with PyInstaller), or did the underlying source code '
	'change during execution?')

Напишем остальные вспомогательные функции:

def colorized_stderr_print(obj):
	"""
	Colorized stderr print.
	
	:param      obj:  The object
	:type       obj:  object
	"""
	for s in obj.split('; '):
		if not s.startswith(f'{DEFAULT_PREFIX} |'):
			s = f'{DEFAULT_PREFIX} | {s}'
		colored = colorize(s)

		with supportTerminalColorsInWindows():
			stderrPrint(colored)


def callOrValue(obj):
	"""
	Call or value.
	
	:param      obj:  The object
	:type       obj:  obj
	
	:returns:   function
	:rtype:     func
	"""
	return obj() if callable(obj) else obj


class Source(executing.Source):
	"""
	Source.
	"""

	def get_text_with_indentation(self, node):
		"""
		Get text with indents.
		
		:param      node:  The node
		:type       node:  node asttokens
		
		:returns:   The text with indentation.
		"""
		result = self.asttokens().get_text(node)
		if '\n' in result:
			result = ' ' * node.first_token.start[1] + result
			result = dedent(result)
		result = result.strip()
		return result


def prefixLines(prefix, s, startAtLine=0):
	"""
	Prefix lines.
	
	:param      prefix:       The prefix
	:param      s:            { parameter_description }
	:param      startAtLine:  The start at line
	"""
	lines = s.splitlines()

	for i in range(startAtLine, len(lines)):
		lines[i] = prefix + lines[i]

	return lines


def prefixFirstLineIndentRemaining(prefix, s):
	"""
	First line indent remaining prefix.
	
	:param      prefix:  The prefix
	:type       prefix:  prefix
	:param      s:       param
	:type       s:       type
	
	:returns:   lines
	:rtype:     list
	"""
	indent = ' ' * len(prefix)
	lines = prefixLines(indent, s, startAtLine=1)
	lines[0] = prefix + lines[0]
	return lines


def formatPair(prefix, arg, value):
	"""
	Formatting pair.
	
	:param      prefix:  The prefix
	:param      arg:     The argument
	:param      value:   The value
	"""
	if arg is _absent:
		argLines = []
		valuePrefix = prefix
	else:
		argLines = prefixFirstLineIndentRemaining(prefix, arg)
		valuePrefix = argLines[-1] + ': '

	looksLikeAString = (value[0] + value[-1]) in ["''", '""']
	if looksLikeAString:  # Align the start of multiline strings.
		valueLines = prefixLines(' ', value, startAtLine=1)
		value = '\n'.join(valueLines)

	valueLines = prefixFirstLineIndentRemaining(valuePrefix, value)
	lines = argLines[:-1] + valueLines
	return '\n'.join(lines)


def singledispatch(func):
	"""
	Single dispatch function.
	
	:param      func:                 The function
	:type       func:                 function
	
	:returns:   func
	:rtype:     func
	
	:raises     NotImplementedError
	"""
	if "singledispatch" not in dir(functools):
		def unsupport_py2(*args, **kwargs):
			raise NotImplementedError(
				"functools.singledispatch is missing in " + sys.version
			)
		func.register = func.unregister = unsupport_py2
		return func

	func = functools.singledispatch(func)

	# add unregister based on https://stackoverflow.com/a/25951784
	closure = dict(zip(func.register.__code__.co_freevars, 
					   func.register.__closure__))
	registry = closure['registry'].cell_contents
	dispatch_cache = closure['dispatch_cache'].cell_contents
	def unregister(cls):
		del registry[cls]
		dispatch_cache.clear()
	func.unregister = unregister
	return func


@singledispatch
def argumentToString(obj):
	"""
	Convert argument to string.
	
	:param      obj:  The object
	:type       obj:  obj
	
	:returns:   String representation of the argument.
	:rtype:     string
	"""
	s = DEFAULT_ARG_TO_STRING_FUNCTION(obj)
	s = s.replace('\\n', '\n')  # Preserve string newlines in output.
	return s

И создадим главный класс - PyDBG_Obj:

class PyDBG_Obj:
	"""Advanced print for debuging.
	
	>>> pydbg_obj | num: 12
					float_int: 12.12
					string: 'Hello'
					boolean: True
					list_array: [1, 2, 3, 'Hi', True, 12.2]
					dictionary: {1: 'HELLO', 2: 'WORLD'}
	
	"""

	_pairDelimiter = '; '
	lineWrapWidth = DEFAULT_LINE_WRAP_WIDTH
	contextDelimiter = DEFAULT_CONTEXT_DELIMITER

	def __init__(self, prefix=DEFAULT_PREFIX,
				 outputFunction=DEFAULT_OUTPUT_FUNCTION,
				 argToStringFunction=argumentToString, includeContext=False,
				 contextAbsPath=False):
		"""
		Initialization.
		
		:param      prefix:               The prefix
		:type       prefix:               prefix
		:param      outputFunction:       The output function
		:type       outputFunction:       output function
		:param      argToStringFunction:  The argument to string function
		:type       argToStringFunction:  function
		:param      includeContext:       The include context
		:type       includeContext:       bool
		:param      contextAbsPath:       The context absolute path
		:type       contextAbsPath:       bool
		"""
		self.enabled = True
		self.prefix = prefix
		self.includeContext = includeContext
		self.outputFunction = outputFunction
		self.argToStringFunction = argToStringFunction
		self.contextAbsPath = contextAbsPath

	def __call__(self, *args):
		"""
		Call magic method.
		
		:param      args:  The arguments
		:type       args:  list
		
		:returns:   passthrough
		:rtype:     list
		"""
		if self.enabled:
			callFrame = inspect.currentframe().f_back
			self.outputFunction(self._format(callFrame, *args))

		if not args:
			passthrough = None
		elif len(args) == 1:
			passthrough = args[0]
		else:
			passthrough = args

		return passthrough

	def format(self, *args):
		"""
		Format arguments.
		
		:param      args:  The arguments
		:type       args:  list
		
		:returns:   formatted out
		:rtype:     call frame formatted
		"""
		callFrame = inspect.currentframe().f_back
		out = self._format(callFrame, *args)
		return out

	def _format(self, callFrame, *args):
		"""
		Format helper function.
		
		:param      callFrame:  The call frame
		:type       callFrame:  call frame
		:param      args:       The arguments
		:type       args:       list
		
		:returns:   formatted
		:rtype:     formatted out
		"""
		prefix = callOrValue(self.prefix)

		context = self._formatContext(callFrame)
		if not args:
			time = self._formatTime()
			out = prefix + context + time
		else:
			if not self.includeContext:
				context = ''
			out = self._formatArgs(
				callFrame, prefix, context, args)

		return out

	def _formatArgs(self, callFrame, prefix, context, args):
		"""
		Format arguments.
		
		:param      callFrame:  The call frame
		:type       callFrame:  call frame
		:param      prefix:     The prefix
		:type       prefix:     prefix
		:param      context:    The context
		:type       context:    content
		:param      args:       The arguments
		:type       args:       args
		
		:returns:   formatted args
		:rtype:     args
		"""
		callNode = Source.executing(callFrame).node
		if callNode is not None:
			source = Source.for_frame(callFrame)
			sanitizedArgStrs = [
				source.get_text_with_indentation(arg)
				for arg in callNode.args]
		else:
			warnings.warn(
				NO_SOURCE_AVAILABLE_WARNING_MESSAGE,
				category=RuntimeWarning, stacklevel=4)
			sanitizedArgStrs = [_absent] * len(args)

		pairs = list(zip(sanitizedArgStrs, args))

		out = self._constructArgumentOutput(prefix, context, pairs)
		return out

	def _constructArgumentOutput(self, prefix, context, pairs):
		"""
		Construct argument output.
		
		:param      prefix:   The prefix
		:type       prefix:   prefix
		:param      context:  context
		:type       context:  context
		:param      pairs:    The pairs
		:type       pairs:    pairs
		
		:returns:   argument output
		:rtype:     string
		"""
		def argPrefix(arg):
			return '%s: ' % arg

		pairs = [(arg, self.argToStringFunction(val)) for arg, val in pairs]
		pairStrs = [
			val if (isLiteral(arg) or arg is _absent)
			else (argPrefix(arg) + val)
			for arg, val in pairs]

		allArgsOnOneLine = self._pairDelimiter.join(pairStrs)
		multilineArgs = len(allArgsOnOneLine.splitlines()) > 1

		contextDelimiter = self.contextDelimiter if context else ''
		allPairs = prefix + context + contextDelimiter + allArgsOnOneLine
		firstLineTooLong = len(allPairs.splitlines()[0]) > self.lineWrapWidth

		if multilineArgs or firstLineTooLong:
			if context:
				lines = [prefix + context] + [
					formatPair(len(prefix) * ' ', arg, value)
					for arg, value in pairs
				]
			else:
				argLines = [
					formatPair('', arg, value)
					for arg, value in pairs
				]
				lines = prefixFirstLineIndentRemaining(prefix, '\n'.join(argLines))
		else:
			lines = [prefix + context + contextDelimiter + allArgsOnOneLine]

		return '\n'.join(lines)

	def _formatContext(self, callFrame):
		"""
		Function for format call frame.
		
		:param      callFrame:  callframe
		:type       callFrame:  call frame
		
		:returns:   context
		:rtype:     string
		"""
		filename, lineNumber, parentFunction = self._getContext(callFrame)

		if parentFunction != '<module>':
			parentFunction = '%s()' % parentFunction

		context = f'{filename}:{lineNumber} in {parentFunction}'
		return context

	def _formatTime(self):
		"""
		Function for format time.
		
		:returns:   format time
		:rtype:     str
		"""
		now = datetime.now()
		formatted = now.strftime('%H:%M:%S.%f')[:-3]
		return ' at %s' % formatted

	def _getContext(self, callFrame):
		"""
		Get context of call frame.
		
		:param      callFrame:  The call frame
		:type       callFrame:  callFrame
		
		:returns:   The context.
		:rtype:     context
		"""
		frameInfo = inspect.getframeinfo(callFrame)
		lineNumber = frameInfo.lineno
		parentFunction = frameInfo.function

		filepath = (realpath if self.contextAbsPath else basename)(frameInfo.filename)
		return filepath, lineNumber, parentFunction

	def enable(self):
		"""
		Enable pydbg_obj.
		"""
		self.enabled = True

	def disable(self):
		"""
		Disable pydbg_obj.
		"""
		self.enabled = False

	def configureOutput(self, prefix=_absent, outputFunction=_absent,
						argToStringFunction=_absent, includeContext=_absent,
						contextAbsPath=_absent):
		"""
		Configure output of pydbg_obj.
		
		:param      prefix:               The prefix
		:type       prefix:               prefix
		:param      outputFunction:       The output function
		:type       outputFunction:       output function
		:param      argToStringFunction:  The argument to string function
		:type       argToStringFunction:  arg to string function
		:param      includeContext:       The include context
		:type       includeContext:       include context
		:param      contextAbsPath:       The context absolute path
		:type       contextAbsPath:       context abs path
		
		:raises     TypeError:            no parameter provided
		"""
		noParameterProvided = all(
			v is _absent for k,v in locals().items() if k != 'self')
		if noParameterProvided:
			raise TypeError('configureOutput() missing at least one argument')

		if prefix is not _absent:
			self.prefix = prefix

		if outputFunction is not _absent:
			self.outputFunction = outputFunction

		if argToStringFunction is not _absent:
			self.argToStringFunction = argToStringFunction

		if includeContext is not _absent:
			self.includeContext = includeContext
		
		if contextAbsPath is not _absent:
			self.contextAbsPath = contextAbsPath

И напишем две функции-декоратора, первая отвечает за сообщение об исполнении функции, вторая нужна для высчитывания скорости выполнения функции:

def debug_func(func, *args, **kwargs):
	"""Decorator for print info about function.

	Arguments:
	---------
	+ func - executed func

	"""
	def wrapper():
		func(*args, **kwargs)
	message = f'debug @ Function {func.__name__}() executed at {datetime.now()}'
	debug_message(message, False)
	return wrapper


def benchmark(func, *args, **kwargs):
	"""Measuring the speed of function execution (decorator).

	Arguments:
	---------
	+ func - executed func

	"""
	start = time()
	def wrapper():
		func(*args, **kwargs)
	end = time()
	total = round(end - start, 2)
	debug_message(f'benchmark {func} @ Execution function {func.__name__} time: {total} sec', True)
	return wrapper

И это весь код на данный момент.

В итоге структура проекта такова:

.
├── example.py
├── poetry.lock
├── pycolor_palette_loguru
│   ├── __init__.py
│   ├── logger
│   │   ├── __init__.py
│   │   ├── logger.py
│   ├── paint.py
│   └── pygments_colorschemes.py
├── pyproject.toml
├── README.md
└── tests
    ├── pytest_example.py
    └── unittest_example.py

Ruff

Ruff — это новый быстроразвивающийся линтер Python-кода, призванный заменить flake8 и isort.

Основным преимуществом Ruff является его скорость: он в 10–100 раз быстрее аналогов (линтер написан на Rust).

Ruff может форматировать код, например, автоматически удалять неиспользуемые импорты. Сортировка и группировка строк импорта практически идентична isort.

Инструмент используется во многих популярных open-source проектах, таких как FastAPI и Pydantic.

Настройка Ruff осуществляется в файле pyproject.toml.

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

ruff check                          # Lint all files in the current directory (and any subdirectories).
ruff check path/to/code/            # Lint all files in `/path/to/code` (and any subdirectories).
ruff check path/to/code/*.py        # Lint all `.py` files in `/path/to/code`.
ruff check path/to/code/to/file.py  # Lint `file.py`.
ruff check @arguments.txt           # Lint using an input file, treating its contents as newline-delimited command-line arguments.
ruff check . --fix 					# Lint all files in current directory and fix

А если как форматтер:

ruff format                          # Format all files in the current directory (and any subdirectories).
ruff format path/to/code/            # Format all files in `/path/to/code` (and any subdirectories).
ruff format path/to/code/*.py        # Format all `.py` files in `/path/to/code`.
ruff format path/to/code/to/file.py  # Format `file.py`.
ruff format @arguments.txt           # Format using an input file, treating its contents as newline-delimited command-line arguments.
ruff format .						 # Format all files in current directory

Для конфигурации ruff'а просто можно изменить файл pyproject.toml (созданный poetry):

# Exclude a variety of commonly ignored directories.
exclude = [
    ".bzr",
    ".direnv",
    ".eggs",
    ".git",
    ".git-rewrite",
    ".hg",
    ".ipynb_checkpoints",
    ".mypy_cache",
    ".nox",
    ".pants.d",
    ".pyenv",
    ".pytest_cache",
    ".pytype",
    ".ruff_cache",
    ".svn",
    ".tox",
    ".venv",
    ".vscode",
    "__pypackages__",
    "_build",
    "buck-out",
    "build",
    "dist",
    "node_modules",
    "site-packages",
    "venv",
]

# Same as Black.
line-length = 88
indent-width = 4

# Assume Python 3.8
target-version = "py38"

[lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`)  codes by default.
select = ["E4", "E7", "E9", "F"]
ignore = []

# Allow fix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []

# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"

[format]
# Like Black, use double quotes for strings.
quote-style = "double"

# Like Black, indent with spaces, rather than tabs.
indent-style = "space"

# Like Black, respect magic trailing commas.
skip-magic-trailing-comma = false

# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"

Финальные штрихи

Создадим файл __init__.py в директории модуля. Этот файл отвечает за инициализацию модуля.

from pycolor_palette_loguru.logger import PyDBG_Obj, benchmark, set_default_theme, debug_func, setup_logger
from pycolor_palette_loguru.paint import info_message, warn_message, error_message, other_message, FG, Style, debug_message, run_exception, BG
from pycolor_palette_loguru.pygments_colorschemes import CatppuccinMocha, SolarizedDark, GruvboxDark

__all__ = (PyDBG_Obj, benchmark, set_default_theme, debug_func, setup_logger, 
	  info_message, error_message, other_message, FG, Style, BG, debug_message, run_exception, BG,
	  CatppuccinMocha, SolarizedDark, GruvboxDark)

❯ Публикация на PyPi

PyPi — официальный репозиторий Python для загрузки и скачивания пакетов. Это официальный ресурс пакетов для третьих лиц, которым управляет Python Software Foundation. После публикации на PyPI пакеты становятся доступными для установки.

Итак, вам потребуется аккаунт на PyPi. Зарегистрироваться можно по этой ссылке.

Дальше вам нужно будет подключить 2FA для безопасности аккаунта:

Аутентификация с помощью токена — это рекомендуемый способ проверки учетной записи PyPI в командной строке. При этом вместо имени пользователя и пароля можно использовать автоматически сгенерированный токен. Токены можно добавлять и отзывать в любое время; с их помощью можно предоставлять доступ к отдельным частям вашей учетной записи. Это делает их безопасными и значительно уменьшает риск взлома. Теперь создадим новый API-токен для учетной записи, для этого перейдите в настройки учетной записи:

Прокрутите вниз и найдите раздел “API tokens”. Нажмите “Add API token”:

Теперь с помощью этого токена можно настроить свои учетные данные в Poetry для подготовки к публикации. Чтобы не добавлять свой API токен к каждой команде, которой он нужен в Poetry, мы сделаем это один раз с помощью команды config:

poetry config pypi-token.pypi your-api-token

Добавленный API токен будет использоваться как учетные данные. Poetry уведомит о том, что ваши учетные данные хранятся в простом текстовом файле. Если использовать обычное имя пользователя и пароль для учетных данных, то это будет небезопасно. Хранение токенов безопасно и удобно, так как они легко удаляются, обновляются и генерируются случайным образом. Но также можно вводить свой API токен вручную для каждой команды.

Далее нам нужно будет собрать и опубликовать пакет через команды:

poetry build
poetry publish

Если на этапе публикации выяснилось, что имя проекта занято, то измените в файле pyproject.toml название проекта, а далее согласно ему измените директорию модуля, и заново запустите сборку и публикацию.

Вы можете просмотреть свои созданные проекты по ссылке.

В итоге, кстати, мой pyproject.toml получился такой:

[tool.poetry]
name = "pycolor_palette-loguru"
version = "0.1.2"
description = "Python library for color beautiful output and logging"
authors = ["Alexeev Bronislav <alexeev.dev@inbox.ru>"]
readme = "README.md"

[project]
name = "pycolor_palette-loguru"
description = "Python library for color beautiful output and logging"
readme = "README.md"
requires-python = ">=3.9"
keywords = ["color", 'icecream', 'loguru', 'logging', 'pycolor', "palette"]
license = {text = "MIT License"}
dynamic = ["version"]

[tool.poetry.dependencies]
python = "^3.12"
rich = "^13.8.1"
ruff = "^0.6.8"
loguru = "^0.7.2"
pygments = "^2.18.0"
colorama = "^0.4.6"
executing = "^2.1.0"
asttokens = "^2.4.1"
pytest = "^8.3.3"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

# Exclude a variety of commonly ignored directories.
exclude = [
    ".bzr",
    ".direnv",
    ".eggs",
    ".git",
    ".git-rewrite",
    ".hg",
    ".ipynb_checkpoints",
    ".mypy_cache",
    ".nox",
    ".pants.d",
    ".pyenv",
    ".pytest_cache",
    ".pytype",
    ".ruff_cache",
    ".svn",
    ".tox",
    ".venv",
    ".vscode",
    "__pypackages__",
    "_build",
    "buck-out",
    "build",
    "dist",
    "node_modules",
    "site-packages",
    "venv",
]

# Same as Black.
line-length = 88
indent-width = 4

# Assume Python 3.8
target-version = "py38"

[lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`)  codes by default.
select = ["E4", "E7", "E9", "F"]
ignore = []

# Allow fix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []

# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"

[format]
# Like Black, use double quotes for strings.
quote-style = "double"

# Like Black, indent with spaces, rather than tabs.
indent-style = "space"

# Like Black, respect magic trailing commas.
skip-magic-trailing-comma = false

# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"

Заключение

Репозиторий исходного кода доступен по ссылке.

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

Надеюсь, вам понравилась данная статья.

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

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud - в нашем Telegram-канале

Перейти

Источники

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


  1. dlinyj
    23.10.2024 09:01

    Очень ценная и полезная статья.

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

    В остальном, большое спасибо за публикацию. Возьму на заметку.


    1. DrArgentum Автор
      23.10.2024 09:01

      Спасибо, учту!


  1. TIEugene
    23.10.2024 09:01

    Статья неплохая, но:


    > #!venv/bin/python3

    1. Прибивать гвоздями к venv - не очень хорошая идея

    2. названия функций/методов таки лучше snake_case (согласно PEP)

    3. прибивать гвоздями точные версии зависимостей тоже не очень хорошая идея; это мешает пакетированию и/или может вызвать конфликт


    1. DrArgentum Автор
      23.10.2024 09:01

      Понял, спасибо за уточнение!


  1. leshabirukov
    23.10.2024 09:01

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

    Недавно изучал вопрос, и увидел совсем другое, - rst * sphinx * autodoc

    Можно пруфы? Ниже мои.

    Newest 'documentation+python' Questions - Stack Overflow

    Related Tags
    ============
    
    python-sphinx × 85
    docstring × 50
    python-3.x × 21
    documentation-generation × 20
    django × 19
    autodoc × 17
    doxygen × 15
    restructuredtext × 15
    pydoc × 14
    pandas × 10
    pdoc × 10
    pycharm × 9

    Repository search results (python+doxygen): 110 results

    Repository search results (python+sphinx):1.3k results

    В стане С++ ,- да, доксиджен рулит. У меня к сфинксу особых предпочтений нет, я вообще хотел asciidoc, но выбрал связку rst * sphinx * autodoc как дефолтную для питона.


    1. DrArgentum Автор
      23.10.2024 09:01

      По личным наблюдениям я доксиген наблюдаю довольно часто.

      Но с вами согласен, да, немного не прав