feat: Add CLI for PyServe with configuration options

- Introduced a new CLI module (`cli.py`) to manage server configurations via command line arguments.
- Added script entry point in `pyproject.toml` for easy access to the CLI.
- Enhanced `Config` class to load configurations from a YAML file.
- Updated `__init__.py` to include `__version__` in the module exports.
- Added optional dependencies for development tools in `pyproject.toml`.
- Implemented logging improvements and error handling in various modules.
- Created tests for the CLI functionality to ensure proper behavior.
- Removed the old `run.py` implementation in favor of the new CLI approach.
This commit is contained in:
Илья Глазунов 2025-09-02 00:20:40 +03:00
parent 83cb7d68b0
commit 84cd1c974f
17 changed files with 1016 additions and 232 deletions

4
.flake8 Normal file
View File

@ -0,0 +1,4 @@
[flake8]
max-line-length = 100
exclude = __pycache__,.git,.venv,venv,build,dist
ignore = E203,W503

128
Makefile Normal file
View File

@ -0,0 +1,128 @@
.PHONY: help install build clean test lint format run dev-install dev-deps check
PYTHON = python3
POETRY = poetry
PACKAGE_NAME = pyserve
GREEN = \033[0;32m
YELLOW = \033[1;33m
RED = \033[0;31m
NC = \033[0m
help:
@echo "$(GREEN)Commands:$(NC)"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " $(YELLOW)%-20s$(NC) %s\n", $$1, $$2}'
install:
@echo "$(GREEN)Installing dependencies...$(NC)"
$(POETRY) install
dev-install:
@echo "$(GREEN)Installing development dependencies...$(NC)"
$(POETRY) install --with dev
dev-deps:
@echo "$(GREEN)Installing additional tools...$(NC)"
$(POETRY) add --group dev pytest pytest-cov black isort mypy flake8
build: clean
@echo "$(GREEN)Building package...$(NC)"
$(POETRY) build
clean:
@echo "$(GREEN)Cleaning temporary files...$(NC)"
rm -rf dist/
rm -rf build/
rm -rf *.egg-info/
find . -type d -name __pycache__ -exec rm -rf {} +
find . -type f -name "*.pyc" -delete
find . -type f -name "*.pyo" -delete
test:
@echo "$(GREEN)Running tests...$(NC)"
$(POETRY) run pytest tests/ -v
test-cov:
@echo "$(GREEN)Running tests with coverage...$(NC)"
$(POETRY) run pytest tests/ -v --cov=$(PACKAGE_NAME) --cov-report=html --cov-report=term
lint:
@echo "$(GREEN)Checking code with linters...$(NC)"
$(POETRY) run flake8 $(PACKAGE_NAME)/
$(POETRY) run mypy $(PACKAGE_NAME)/
format:
@echo "$(GREEN)Formatting code...$(NC)"
$(POETRY) run black $(PACKAGE_NAME)/
$(POETRY) run isort $(PACKAGE_NAME)/
check: lint test
run:
@echo "$(GREEN)Starting server in development mode...$(NC)"
$(POETRY) run python run.py --debug
run-prod:
@echo "$(GREEN)Starting server in production mode...$(NC)"
$(POETRY) run $(PACKAGE_NAME)
install-package: build
@echo "$(GREEN)Installing package locally...$(NC)"
$(POETRY) install
publish-test: build
@echo "$(YELLOW)Publishing to Test PyPI...$(NC)"
$(POETRY) publish --repository testpypi
publish: build
@echo "$(RED)Publishing to PyPI...$(NC)"
$(POETRY) publish
version:
@echo "$(GREEN)Current version:$(NC)"
$(POETRY) version
version-patch:
@echo "$(GREEN)Increasing patch version...$(NC)"
$(POETRY) version patch
version-minor:
@echo "$(GREEN)Increasing minor version...$(NC)"
$(POETRY) version minor
version-major:
@echo "$(GREEN)Increasing major version...$(NC)"
$(POETRY) version major
shell:
@echo "$(GREEN)Opening Poetry shell...$(NC)"
$(POETRY) shell
env-info:
@echo "$(GREEN)Environment information:$(NC)"
$(POETRY) env info
deps-update:
@echo "$(GREEN)Updating dependencies...$(NC)"
$(POETRY) update
deps-show:
@echo "$(GREEN)Dependency tree:$(NC)"
$(POETRY) show --tree
config-create:
@if [ ! -f config.yaml ]; then \
echo "$(GREEN)Creating config.yaml from config.example.yaml...$(NC)"; \
cp config.example.yaml config.yaml; \
else \
echo "$(YELLOW)config.yaml already exists$(NC)"; \
fi
logs:
@echo "$(GREEN)Last server logs:$(NC)"
@if [ -f logs/pyserve.log ]; then tail -f logs/pyserve.log; else echo "$(RED)Log file not found$(NC)"; fi
init: dev-install config-create
@echo "$(GREEN)Project initialized for development!$(NC)"
.DEFAULT_GOAL := help

109
README.md
View File

@ -8,7 +8,7 @@ PyServe is a modern, async HTTP server written in Python. Originally created for
## Project Overview ## Project Overview
PyServe v0.4.2 introduces a completely refactored architecture with modern async/await syntax and new exciting features like **Vibe-Serving** - AI-powered dynamic content generation. PyServe v0.6.0 introduces a completely refactored architecture with modern async/await syntax and new exciting features like **Vibe-Serving** - AI-powered dynamic content generation.
### Key Features: ### Key Features:
@ -23,29 +23,130 @@ PyServe v0.4.2 introduces a completely refactored architecture with modern async
- **Modular Extensions** - Plugin-like architecture for security, caching, monitoring - **Modular Extensions** - Plugin-like architecture for security, caching, monitoring
- **Beautiful Logging** - Colored terminal output with file rotation - **Beautiful Logging** - Colored terminal output with file rotation
- **Error Handling** - Styled error pages and graceful fallbacks - **Error Handling** - Styled error pages and graceful fallbacks
- **CLI Interface** - Command-line interface for easy deployment and configuration
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
- Python 3.12 or higher - Python 3.12 or higher
- Dependencies: `pip install -r requirements.txt` - Poetry (recommended) or pip
### Installation ### Installation
#### Via Poetry (рекомендуется)
```bash ```bash
git clone https://github.com/ShiftyX1/PyServe.git git clone https://github.com/ShiftyX1/PyServe.git
cd PyServe cd PyServe
pip install -r requirements.txt make init # Инициализация проекта для разработки
```
#### Или установка пакета
```bash
# Локальная установка
make install-package
# После установки можно использовать команду pyserve
pyserve --help
``` ```
### Running the Server ### Running the Server
Basic startup: #### Используя Makefile (рекомендуется)
```bash ```bash
# Запуск в режиме разработки
make run
# Запуск в продакшн режиме
make run-prod
# Показать все доступные команды
make help
```
#### Используя CLI напрямую
```bash
# После установки пакета
pyserve
# Или через Poetry
poetry run pyserve
# Или старый способ (для обратной совместимости)
python run.py python run.py
``` ```
#### Опции командной строки
```bash
# Справка
pyserve --help
# Кастомный конфиг
pyserve -c /path/to/config.yaml
# Переопределить хост и порт
pyserve --host 0.0.0.0 --port 9000
# Режим отладки
pyserve --debug
# Показать версию
pyserve --version
```
## Development
### Makefile Commands
```bash
make help # Показать справку по командам
make install # Установить зависимости
make dev-install # Установить зависимости для разработки
make build # Собрать пакет
make test # Запустить тесты
make test-cov # Тесты с покрытием кода
make lint # Проверить код линтерами
make format # Форматировать код
make clean # Очистить временные файлы
make version # Показать версию
make publish-test # Опубликовать в Test PyPI
make publish # Опубликовать в PyPI
```
### Project Structure
```
pyserveX/
├── pyserve/ # Основной пакет
│ ├── __init__.py
│ ├── cli.py # CLI интерфейс
│ ├── server.py # Основной сервер
│ ├── config.py # Система конфигурации
│ ├── routing.py # Маршрутизация
│ ├── extensions.py # Расширения
│ └── logging_utils.py
├── tests/ # Тесты
├── static/ # Статические файлы
├── templates/ # Шаблоны
├── logs/ # Логи
├── Makefile # Автоматизация задач
├── pyproject.toml # Конфигурация проекта
├── config.yaml # Конфигурация сервера
└── run.py # Точка входа (обратная совместимость)
```
### Configuration
Create `config.yaml` from example:
```bash
make config-create
```
Running with specific configuration: Running with specific configuration:
```bash ```bash
python run.py -H 0.0.0.0 -p 8080 python run.py -H 0.0.0.0 -p 8080

17
mypy.ini Normal file
View File

@ -0,0 +1,17 @@
[mypy]
python_version = 3.12
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True
disallow_incomplete_defs = True
check_untyped_defs = True
disallow_untyped_decorators = True
no_implicit_optional = True
warn_redundant_casts = True
warn_unused_ignores = True
warn_no_return = True
warn_unreachable = True
strict_equality = True
[mypy-tests.*]
disallow_untyped_defs = False

425
poetry.lock generated
View File

@ -20,13 +20,58 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras] [package.extras]
trio = ["trio (>=0.26.1)"] trio = ["trio (>=0.26.1)"]
[[package]]
name = "black"
version = "25.1.0"
description = "The uncompromising code formatter."
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"},
{file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"},
{file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"},
{file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"},
{file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"},
{file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"},
{file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"},
{file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"},
{file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"},
{file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"},
{file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"},
{file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"},
{file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"},
{file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"},
{file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"},
{file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"},
{file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"},
{file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"},
{file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"},
{file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"},
{file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"},
{file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"},
]
[package.dependencies]
click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
packaging = ">=22.0"
pathspec = ">=0.9.0"
platformdirs = ">=2"
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.10)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]] [[package]]
name = "click" name = "click"
version = "8.2.1" version = "8.2.1"
description = "Composable command line interface toolkit" description = "Composable command line interface toolkit"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"},
{file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"},
@ -41,13 +86,131 @@ version = "0.4.6"
description = "Cross-platform colored terminal text." description = "Cross-platform colored terminal text."
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main"] groups = ["main", "dev"]
markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" markers = "platform_system == \"Windows\" or sys_platform == \"win32\""
files = [ files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
[[package]]
name = "coverage"
version = "7.10.6"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"},
{file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"},
{file = "coverage-7.10.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460"},
{file = "coverage-7.10.6-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd"},
{file = "coverage-7.10.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb"},
{file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6"},
{file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945"},
{file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e"},
{file = "coverage-7.10.6-cp310-cp310-win32.whl", hash = "sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1"},
{file = "coverage-7.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528"},
{file = "coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f"},
{file = "coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc"},
{file = "coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a"},
{file = "coverage-7.10.6-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a"},
{file = "coverage-7.10.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62"},
{file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153"},
{file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5"},
{file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619"},
{file = "coverage-7.10.6-cp311-cp311-win32.whl", hash = "sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba"},
{file = "coverage-7.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e"},
{file = "coverage-7.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c"},
{file = "coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea"},
{file = "coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634"},
{file = "coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6"},
{file = "coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9"},
{file = "coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c"},
{file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a"},
{file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5"},
{file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972"},
{file = "coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d"},
{file = "coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629"},
{file = "coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80"},
{file = "coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6"},
{file = "coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80"},
{file = "coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003"},
{file = "coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27"},
{file = "coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4"},
{file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d"},
{file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc"},
{file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc"},
{file = "coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e"},
{file = "coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32"},
{file = "coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2"},
{file = "coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b"},
{file = "coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393"},
{file = "coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27"},
{file = "coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df"},
{file = "coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb"},
{file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282"},
{file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4"},
{file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21"},
{file = "coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0"},
{file = "coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5"},
{file = "coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b"},
{file = "coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e"},
{file = "coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb"},
{file = "coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034"},
{file = "coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1"},
{file = "coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a"},
{file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb"},
{file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d"},
{file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747"},
{file = "coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5"},
{file = "coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713"},
{file = "coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32"},
{file = "coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65"},
{file = "coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6"},
{file = "coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0"},
{file = "coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e"},
{file = "coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5"},
{file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7"},
{file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5"},
{file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0"},
{file = "coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7"},
{file = "coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930"},
{file = "coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b"},
{file = "coverage-7.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352"},
{file = "coverage-7.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612"},
{file = "coverage-7.10.6-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b"},
{file = "coverage-7.10.6-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144"},
{file = "coverage-7.10.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b"},
{file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862"},
{file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2"},
{file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78"},
{file = "coverage-7.10.6-cp39-cp39-win32.whl", hash = "sha256:2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c"},
{file = "coverage-7.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf"},
{file = "coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3"},
{file = "coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90"},
]
[package.extras]
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]]
name = "flake8"
version = "7.3.0"
description = "the modular source code checker: pep8 pyflakes and co"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"},
{file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"},
]
[package.dependencies]
mccabe = ">=0.7.0,<0.8.0"
pycodestyle = ">=2.14.0,<2.15.0"
pyflakes = ">=3.4.0,<3.5.0"
[[package]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
@ -131,6 +294,256 @@ files = [
[package.extras] [package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.1.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
[[package]]
name = "isort"
version = "6.0.1"
description = "A Python utility / library to sort Python imports."
optional = false
python-versions = ">=3.9.0"
groups = ["main", "dev"]
files = [
{file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"},
{file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"},
]
[package.extras]
colors = ["colorama"]
plugins = ["setuptools"]
[[package]]
name = "mccabe"
version = "0.7.0"
description = "McCabe checker, plugin for flake8"
optional = false
python-versions = ">=3.6"
groups = ["main", "dev"]
files = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
]
[[package]]
name = "mypy"
version = "1.17.1"
description = "Optional static typing for Python"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "mypy-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972"},
{file = "mypy-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7"},
{file = "mypy-1.17.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a580f8a70c69e4a75587bd925d298434057fe2a428faaf927ffe6e4b9a98df"},
{file = "mypy-1.17.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd86bb649299f09d987a2eebb4d52d10603224500792e1bee18303bbcc1ce390"},
{file = "mypy-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a76906f26bd8d51ea9504966a9c25419f2e668f012e0bdf3da4ea1526c534d94"},
{file = "mypy-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:e79311f2d904ccb59787477b7bd5d26f3347789c06fcd7656fa500875290264b"},
{file = "mypy-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58"},
{file = "mypy-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5"},
{file = "mypy-1.17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd"},
{file = "mypy-1.17.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b"},
{file = "mypy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5"},
{file = "mypy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b"},
{file = "mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb"},
{file = "mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403"},
{file = "mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056"},
{file = "mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341"},
{file = "mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb"},
{file = "mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19"},
{file = "mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7"},
{file = "mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81"},
{file = "mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6"},
{file = "mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849"},
{file = "mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14"},
{file = "mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a"},
{file = "mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733"},
{file = "mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd"},
{file = "mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0"},
{file = "mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a"},
{file = "mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91"},
{file = "mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed"},
{file = "mypy-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5d1092694f166a7e56c805caaf794e0585cabdbf1df36911c414e4e9abb62ae9"},
{file = "mypy-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79d44f9bfb004941ebb0abe8eff6504223a9c1ac51ef967d1263c6572bbebc99"},
{file = "mypy-1.17.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b01586eed696ec905e61bd2568f48740f7ac4a45b3a468e6423a03d3788a51a8"},
{file = "mypy-1.17.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43808d9476c36b927fbcd0b0255ce75efe1b68a080154a38ae68a7e62de8f0f8"},
{file = "mypy-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:feb8cc32d319edd5859da2cc084493b3e2ce5e49a946377663cc90f6c15fb259"},
{file = "mypy-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7598cf74c3e16539d4e2f0b8d8c318e00041553d83d4861f87c7a72e95ac24d"},
{file = "mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9"},
{file = "mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01"},
]
[package.dependencies]
mypy_extensions = ">=1.0.0"
pathspec = ">=0.9.0"
typing_extensions = ">=4.6.0"
[package.extras]
dmypy = ["psutil (>=4.0)"]
faster-cache = ["orjson"]
install-types = ["pip"]
mypyc = ["setuptools (>=50)"]
reports = ["lxml"]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
]
[[package]]
name = "packaging"
version = "25.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
[[package]]
name = "pathspec"
version = "0.12.1"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
]
[[package]]
name = "platformdirs"
version = "4.4.0"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"},
{file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"},
]
[package.extras]
docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"]
type = ["mypy (>=1.14.1)"]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pycodestyle"
version = "2.14.0"
description = "Python style guide checker"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"},
{file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"},
]
[[package]]
name = "pyflakes"
version = "3.4.0"
description = "passive checker of Python programs"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"},
{file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"},
]
[[package]]
name = "pygments"
version = "2.19.2"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pytest"
version = "8.4.1"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"},
{file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1"
packaging = ">=20"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-cov"
version = "6.2.1"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5"},
{file = "pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2"},
]
[package.dependencies]
coverage = {version = ">=7.5", extras = ["toml"]}
pluggy = ">=1.2"
pytest = ">=6.2.5"
[package.extras]
testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.1.1" version = "1.1.1"
@ -246,8 +659,7 @@ version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+" description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main", "dev"]
markers = "python_version == \"3.12\""
files = [ files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
@ -530,7 +942,10 @@ files = [
{file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"},
] ]
[extras]
dev = ["black", "flake8", "isort", "mypy", "pytest", "pytest-cov"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.12" python-versions = ">=3.12"
content-hash = "056f0e4f1dd06e7d3a7fb2e5c6a891791c1cb18ce77466c4306de7cc8242cb73" content-hash = "a69856492efdf3ed2272517f43842f06b7199519b2da459c6aadc863e2429c45"

View File

@ -14,7 +14,61 @@ dependencies = [
"pyyaml (>=6.0,<7.0)" "pyyaml (>=6.0,<7.0)"
] ]
[project.scripts]
pyserve = "pyserve.cli:main"
[project.optional-dependencies]
dev = [
"pytest",
"pytest-cov",
"black",
"isort",
"mypy",
"flake8"
]
[build-system] [build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"] requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.black]
line-length = 88
target-version = ['py312']
include = '\.pyi?$'
exclude = '''
/(
\.eggs
| \.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| _build
| buck-out
| build
| dist
)/
'''
[tool.isort]
profile = "black"
multi_line_output = 3
line_length = 88
known_first_party = ["pyserve"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = ["-v", "--tb=short"]
[tool.poetry.group.dev.dependencies]
pytest = "^8.4.1"
pytest-cov = "^6.2.1"
black = "^25.1.0"
isort = "^6.0.1"
mypy = "^1.17.1"
flake8 = "^7.3.0"

View File

@ -8,4 +8,4 @@ __author__ = "Илья Глазунов"
from .server import PyServeServer from .server import PyServeServer
from .config import Config from .config import Config
__all__ = ["PyServeServer", "Config"] __all__ = ["PyServeServer", "Config", "__version__"]

72
pyserve/cli.py Normal file
View File

@ -0,0 +1,72 @@
import sys
import argparse
from pathlib import Path
from . import PyServeServer, Config, __version__
def main():
parser = argparse.ArgumentParser(
description="PyServe - HTTP web server",
prog="pyserve"
)
parser.add_argument(
"-c", "--config",
default="config.yaml",
help="Path to configuration file (default: config.yaml)"
)
parser.add_argument(
"--host",
help="Host to bind the server to"
)
parser.add_argument(
"--port",
type=int,
help="Port to bind the server to"
)
parser.add_argument(
"--debug",
action="store_true",
help="Enable debug mode"
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {__version__}"
)
args = parser.parse_args()
config_path = args.config
if not Path(config_path).exists():
print(f"Configuration file {config_path} not found")
print("Using default configuration")
config = Config()
else:
try:
config = Config.from_yaml(config_path)
except Exception as e:
print(f"Configuration loading error: {e}")
sys.exit(1)
if args.host:
config.server.host = args.host
if args.port:
config.server.port = args.port
if args.debug:
config.logging.level = "DEBUG"
server = PyServeServer(config)
try:
print(f"Starting PyServe server on {config.server.host}:{config.server.port}")
server.run()
except KeyboardInterrupt:
print("\nServer stopped by user")
except Exception as e:
print(f"Server startup error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -1,7 +1,6 @@
import yaml import yaml
import os import os
from pathlib import Path from typing import Dict, Any, List
from typing import Dict, Any, List, Optional
from dataclasses import dataclass, field from dataclasses import dataclass, field
import logging import logging
from .logging_utils import setup_logging from .logging_utils import setup_logging
@ -60,26 +59,26 @@ class Config:
try: try:
with open(file_path, 'r', encoding='utf-8') as f: with open(file_path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f) data = yaml.safe_load(f)
return cls._from_dict(data) return cls._from_dict(data)
except FileNotFoundError: except FileNotFoundError:
logging.warning(f"Конфигурационный файл {file_path} не найден. Используются значения по умолчанию.") logging.warning(f"Configuration file {file_path} not found. Using default values.")
return cls() return cls()
except yaml.YAMLError as e: except yaml.YAMLError as e:
logging.error(f"Ошибка парсинга YAML файла {file_path}: {e}") logging.error(f"YAML file parsing error {file_path}: {e}")
raise raise
@classmethod @classmethod
def _from_dict(cls, data: Dict[str, Any]) -> "Config": def _from_dict(cls, data: Dict[str, Any]) -> "Config":
config = cls() config = cls()
if 'http' in data: if 'http' in data:
http_data = data['http'] http_data = data['http']
config.http = HttpConfig( config.http = HttpConfig(
static_dir=http_data.get('static_dir', config.http.static_dir), static_dir=http_data.get('static_dir', config.http.static_dir),
templates_dir=http_data.get('templates_dir', config.http.templates_dir) templates_dir=http_data.get('templates_dir', config.http.templates_dir)
) )
if 'server' in data: if 'server' in data:
server_data = data['server'] server_data = data['server']
config.server = ServerConfig( config.server = ServerConfig(
@ -89,7 +88,7 @@ class Config:
default_root=server_data.get('default_root', config.server.default_root), default_root=server_data.get('default_root', config.server.default_root),
redirect_instructions=server_data.get('redirect_instructions', {}) redirect_instructions=server_data.get('redirect_instructions', {})
) )
if 'ssl' in data: if 'ssl' in data:
ssl_data = data['ssl'] ssl_data = data['ssl']
config.ssl = SSLConfig( config.ssl = SSLConfig(
@ -97,7 +96,7 @@ class Config:
cert_file=ssl_data.get('cert_file', config.ssl.cert_file), cert_file=ssl_data.get('cert_file', config.ssl.cert_file),
key_file=ssl_data.get('key_file', config.ssl.key_file) key_file=ssl_data.get('key_file', config.ssl.key_file)
) )
if 'logging' in data: if 'logging' in data:
log_data = data['logging'] log_data = data['logging']
config.logging = LoggingConfig( config.logging = LoggingConfig(
@ -105,7 +104,7 @@ class Config:
console_output=log_data.get('console_output', config.logging.console_output), console_output=log_data.get('console_output', config.logging.console_output),
log_file=log_data.get('log_file', config.logging.log_file) log_file=log_data.get('log_file', config.logging.log_file)
) )
if 'extensions' in data: if 'extensions' in data:
for ext_data in data['extensions']: for ext_data in data['extensions']:
extension = ExtensionConfig( extension = ExtensionConfig(
@ -113,40 +112,39 @@ class Config:
config=ext_data.get('config', {}) config=ext_data.get('config', {})
) )
config.extensions.append(extension) config.extensions.append(extension)
return config return config
def validate(self) -> bool: def validate(self) -> bool:
errors = [] errors = []
if not os.path.exists(self.http.static_dir): if not os.path.exists(self.http.static_dir):
errors.append(f"Статическая директория не существует: {self.http.static_dir}") errors.append(f"Static directory does not exist: {self.http.static_dir}")
if self.ssl.enabled: if self.ssl.enabled:
if not os.path.exists(self.ssl.cert_file): if not os.path.exists(self.ssl.cert_file):
errors.append(f"SSL сертификат не найден: {self.ssl.cert_file}") errors.append(f"SSL certificate not found: {self.ssl.cert_file}")
if not os.path.exists(self.ssl.key_file): if not os.path.exists(self.ssl.key_file):
errors.append(f"SSL ключ не найден: {self.ssl.key_file}") errors.append(f"SSL key not found: {self.ssl.key_file}")
if not (1 <= self.server.port <= 65535): if not (1 <= self.server.port <= 65535):
errors.append(f"Некорректный порт: {self.server.port}") errors.append(f"Invalid port: {self.server.port}")
log_dir = os.path.dirname(self.logging.log_file) log_dir = os.path.dirname(self.logging.log_file)
if log_dir and not os.path.exists(log_dir): if log_dir and not os.path.exists(log_dir):
try: try:
os.makedirs(log_dir, exist_ok=True) os.makedirs(log_dir, exist_ok=True)
except OSError as e: except OSError as e:
errors.append(f"Невозможно создать директорию для логов: {e}") errors.append(f"Unable to create log directory: {e}")
if errors: if errors:
for error in errors: for error in errors:
logging.error(f"Ошибка конфигурации: {error}") logging.error(f"Configuration error: {error}")
return False return False
return True return True
def setup_logging(self) -> None: def setup_logging(self) -> None:
"""Настройка системы логирования через кастомный менеджер"""
config_dict = { config_dict = {
'level': self.logging.level, 'level': self.logging.level,
'console_output': self.logging.console_output, 'console_output': self.logging.console_output,

View File

@ -7,48 +7,48 @@ from .logging_utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
class Extension(ABC): class Extension(ABC):
def __init__(self, config: Dict[str, Any]): def __init__(self, config: Dict[str, Any]):
self.config = config self.config = config
self.enabled = True self.enabled = True
@abstractmethod @abstractmethod
async def process_request(self, request: Request) -> Optional[Response]: async def process_request(self, request: Request) -> Optional[Response]:
pass pass
@abstractmethod @abstractmethod
async def process_response(self, request: Request, response: Response) -> Response: async def process_response(self, request: Request, response: Response) -> Response:
pass pass
def initialize(self) -> None: def initialize(self) -> None:
pass pass
def cleanup(self) -> None: def cleanup(self) -> None:
pass pass
class RoutingExtension(Extension): class RoutingExtension(Extension):
def __init__(self, config: Dict[str, Any]): def __init__(self, config: Dict[str, Any]):
super().__init__(config) super().__init__(config)
from .routing import create_router_from_config from .routing import create_router_from_config
regex_locations = config.get("regex_locations", {}) regex_locations = config.get("regex_locations", {})
self.router = create_router_from_config(regex_locations) self.router = create_router_from_config(regex_locations)
from .routing import RequestHandler from .routing import RequestHandler
self.handler = RequestHandler(self.router) self.handler = RequestHandler(self.router)
async def process_request(self, request: Request) -> Optional[Response]: async def process_request(self, request: Request) -> Optional[Response]:
try: try:
return await self.handler.handle(request) return await self.handler.handle(request)
except Exception as e: except Exception as e:
logger.error(f"Ошибка в RoutingExtension: {e}") logger.error(f"Error in RoutingExtension: {e}")
return None return None
async def process_response(self, request: Request, response: Response) -> Response: async def process_response(self, request: Request, response: Response) -> Response:
return response return response
class SecurityExtension(Extension): class SecurityExtension(Extension):
def __init__(self, config: Dict[str, Any]): def __init__(self, config: Dict[str, Any]):
super().__init__(config) super().__init__(config)
self.allowed_ips = config.get("allowed_ips", []) self.allowed_ips = config.get("allowed_ips", [])
@ -58,76 +58,76 @@ class SecurityExtension(Extension):
"X-Frame-Options": "DENY", "X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block" "X-XSS-Protection": "1; mode=block"
}) })
async def process_request(self, request: Request) -> Optional[Response]: async def process_request(self, request: Request) -> Optional[Response]:
client_ip = request.client.host if request.client else "unknown" client_ip = request.client.host if request.client else "unknown"
if self.blocked_ips and client_ip in self.blocked_ips: if self.blocked_ips and client_ip in self.blocked_ips:
logger.warning(f"Заблокирован запрос от IP: {client_ip}") logger.warning(f"Blocked request from IP: {client_ip}")
from starlette.responses import PlainTextResponse from starlette.responses import PlainTextResponse
return PlainTextResponse("403 Forbidden", status_code=403) return PlainTextResponse("403 Forbidden", status_code=403)
if self.allowed_ips and client_ip not in self.allowed_ips: if self.allowed_ips and client_ip not in self.allowed_ips:
logger.warning(f"Запрещен доступ для IP: {client_ip}") logger.warning(f"Access denied for IP: {client_ip}")
from starlette.responses import PlainTextResponse from starlette.responses import PlainTextResponse
return PlainTextResponse("403 Forbidden", status_code=403) return PlainTextResponse("403 Forbidden", status_code=403)
return None return None
async def process_response(self, request: Request, response: Response) -> Response: async def process_response(self, request: Request, response: Response) -> Response:
for header, value in self.security_headers.items(): for header, value in self.security_headers.items():
response.headers[header] = value response.headers[header] = value
return response return response
class CachingExtension(Extension): class CachingExtension(Extension):
def __init__(self, config: Dict[str, Any]): def __init__(self, config: Dict[str, Any]):
super().__init__(config) super().__init__(config)
self.cache: Dict[str, Any] = {} self.cache: Dict[str, Any] = {}
self.cache_patterns = config.get("cache_patterns", []) self.cache_patterns = config.get("cache_patterns", [])
self.cache_ttl = config.get("cache_ttl", 3600) self.cache_ttl = config.get("cache_ttl", 3600)
async def process_request(self, request: Request) -> Optional[Response]: async def process_request(self, request: Request) -> Optional[Response]:
# TODO: Реализовать проверку кэша # TODO: Implement cache check
return None return None
async def process_response(self, request: Request, response: Response) -> Response: async def process_response(self, request: Request, response: Response) -> Response:
# TODO: Реализовать кэширование ответов # TODO: Implement response caching
return response return response
class MonitoringExtension(Extension): class MonitoringExtension(Extension):
def __init__(self, config: Dict[str, Any]): def __init__(self, config: Dict[str, Any]):
super().__init__(config) super().__init__(config)
self.request_count = 0 self.request_count = 0
self.error_count = 0 self.error_count = 0
self.response_times = [] self.response_times = []
self.enable_metrics = config.get("enable_metrics", True) self.enable_metrics = config.get("enable_metrics", True)
async def process_request(self, request: Request) -> Optional[Response]: async def process_request(self, request: Request) -> Optional[Response]:
if self.enable_metrics: if self.enable_metrics:
self.request_count += 1 self.request_count += 1
request.state.start_time = __import__('time').time() request.state.start_time = __import__('time').time()
return None return None
async def process_response(self, request: Request, response: Response) -> Response: async def process_response(self, request: Request, response: Response) -> Response:
if self.enable_metrics and hasattr(request.state, 'start_time'): if self.enable_metrics and hasattr(request.state, 'start_time'):
response_time = __import__('time').time() - request.state.start_time response_time = __import__('time').time() - request.state.start_time
self.response_times.append(response_time) self.response_times.append(response_time)
if response.status_code >= 400: if response.status_code >= 400:
self.error_count += 1 self.error_count += 1
logger.info(f"Request: {request.method} {request.url.path} - " logger.info(f"Request: {request.method} {request.url.path} - "
f"Status: {response.status_code} - " f"Status: {response.status_code} - "
f"Time: {response_time:.3f}s") f"Time: {response_time:.3f}s")
return response return response
def get_metrics(self) -> Dict[str, Any]: def get_metrics(self) -> Dict[str, Any]:
avg_response_time = (sum(self.response_times) / len(self.response_times) avg_response_time = (sum(self.response_times) / len(self.response_times)
if self.response_times else 0) if self.response_times else 0)
return { return {
"request_count": self.request_count, "request_count": self.request_count,
"error_count": self.error_count, "error_count": self.error_count,
@ -137,7 +137,7 @@ class MonitoringExtension(Extension):
} }
class ExtensionManager: class ExtensionManager:
def __init__(self): def __init__(self):
self.extensions: List[Extension] = [] self.extensions: List[Extension] = []
self.extension_registry = { self.extension_registry = {
@ -146,15 +146,15 @@ class ExtensionManager:
"caching": CachingExtension, "caching": CachingExtension,
"monitoring": MonitoringExtension "monitoring": MonitoringExtension
} }
def register_extension_type(self, name: str, extension_class: type) -> None: def register_extension_type(self, name: str, extension_class: type) -> None:
self.extension_registry[name] = extension_class self.extension_registry[name] = extension_class
def load_extension(self, extension_type: str, config: Dict[str, Any]) -> None: def load_extension(self, extension_type: str, config: Dict[str, Any]) -> None:
if extension_type not in self.extension_registry: if extension_type not in self.extension_registry:
logger.error(f"Неизвестный тип расширения: {extension_type}") logger.error(f"Неизвестный тип расширения: {extension_type}")
return return
try: try:
extension_class = self.extension_registry[extension_type] extension_class = self.extension_registry[extension_type]
extension = extension_class(config) extension = extension_class(config)
@ -163,38 +163,38 @@ class ExtensionManager:
logger.info(f"Загружено расширение: {extension_type}") logger.info(f"Загружено расширение: {extension_type}")
except Exception as e: except Exception as e:
logger.error(f"Ошибка загрузки расширения {extension_type}: {e}") logger.error(f"Ошибка загрузки расширения {extension_type}: {e}")
async def process_request(self, request: Request) -> Optional[Response]: async def process_request(self, request: Request) -> Optional[Response]:
for extension in self.extensions: for extension in self.extensions:
if not extension.enabled: if not extension.enabled:
continue continue
try: try:
response = await extension.process_request(request) response = await extension.process_request(request)
if response is not None: if response is not None:
return response return response
except Exception as e: except Exception as e:
logger.error(f"Ошибка в расширении {type(extension).__name__}: {e}") logger.error(f"Ошибка в расширении {type(extension).__name__}: {e}")
return None return None
async def process_response(self, request: Request, response: Response) -> Response: async def process_response(self, request: Request, response: Response) -> Response:
for extension in self.extensions: for extension in self.extensions:
if not extension.enabled: if not extension.enabled:
continue continue
try: try:
response = await extension.process_response(request, response) response = await extension.process_response(request, response)
except Exception as e: except Exception as e:
logger.error(f"Ошибка в расширении {type(extension).__name__}: {e}") logger.error(f"Ошибка в расширении {type(extension).__name__}: {e}")
return response return response
def cleanup(self) -> None: def cleanup(self) -> None:
for extension in self.extensions: for extension in self.extensions:
try: try:
extension.cleanup() extension.cleanup()
except Exception as e: except Exception as e:
logger.error(f"Ошибка при очистке расширения {type(extension).__name__}: {e}") logger.error(f"Ошибка при очистке расширения {type(extension).__name__}: {e}")
self.extensions.clear() self.extensions.clear()

View File

@ -13,7 +13,7 @@ from typing import Dict, Any, List
from . import __version__ from . import __version__
class UvicornLogFilter(logging.Filter): class UvicornLogFilter(logging.Filter):
def filter(self, record): def filter(self, record):
if hasattr(record, 'name') and 'uvicorn.access' in record.name: if hasattr(record, 'name') and 'uvicorn.access' in record.name:
if hasattr(record, 'getMessage'): if hasattr(record, 'getMessage'):
@ -27,31 +27,31 @@ class UvicornLogFilter(logging.Filter):
method_path = request_part[0] method_path = request_part[0]
status_part = request_part[1] status_part = request_part[1]
record.msg = f"Access: {client_info} - {method_path} - {status_part}" record.msg = f"Access: {client_info} - {method_path} - {status_part}"
return True return True
class PyServeFormatter(logging.Formatter): class PyServeFormatter(logging.Formatter):
COLORS = { COLORS = {
'DEBUG': '\033[36m', # Cyan 'DEBUG': '\033[36m', # Cyan
'INFO': '\033[32m', # Green 'INFO': '\033[32m', # Green
'WARNING': '\033[33m', # Yellow 'WARNING': '\033[33m', # Yellow
'ERROR': '\033[31m', # Red 'ERROR': '\033[31m', # Red
'CRITICAL': '\033[35m', # Magenta 'CRITICAL': '\033[35m', # Magenta
'RESET': '\033[0m' # Reset 'RESET': '\033[0m' # Reset
} }
def __init__(self, use_colors: bool = True, show_module: bool = True, *args, **kwargs): def __init__(self, use_colors: bool = True, show_module: bool = True, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.use_colors = use_colors and hasattr(sys.stderr, 'isatty') and sys.stderr.isatty() self.use_colors = use_colors and hasattr(sys.stderr, 'isatty') and sys.stderr.isatty()
self.show_module = show_module self.show_module = show_module
def format(self, record): def format(self, record):
if self.use_colors: if self.use_colors:
levelname = record.levelname levelname = record.levelname
if levelname in self.COLORS: if levelname in self.COLORS:
record.levelname = f"{self.COLORS[levelname]}{levelname}{self.COLORS['RESET']}" record.levelname = f"{self.COLORS[levelname]}{levelname}{self.COLORS['RESET']}"
if self.show_module and hasattr(record, 'name'): if self.show_module and hasattr(record, 'name'):
name = record.name name = record.name
if name.startswith('uvicorn'): if name.startswith('uvicorn'):
@ -60,30 +60,30 @@ class PyServeFormatter(logging.Formatter):
pass pass
elif name.startswith('starlette'): elif name.startswith('starlette'):
record.name = 'starlette' record.name = 'starlette'
return super().format(record) return super().format(record)
class AccessLogHandler(logging.Handler): class AccessLogHandler(logging.Handler):
def __init__(self, logger_name: str = 'pyserve.access'): def __init__(self, logger_name: str = 'pyserve.access'):
super().__init__() super().__init__()
self.access_logger = logging.getLogger(logger_name) self.access_logger = logging.getLogger(logger_name)
def emit(self, record): def emit(self, record):
self.access_logger.handle(record) self.access_logger.handle(record)
class PyServeLogManager: class PyServeLogManager:
def __init__(self): def __init__(self):
self.configured = False self.configured = False
self.handlers: Dict[str, logging.Handler] = {} self.handlers: Dict[str, logging.Handler] = {}
self.loggers: Dict[str, logging.Logger] = {} self.loggers: Dict[str, logging.Logger] = {}
self.original_handlers: Dict[str, List[logging.Handler]] = {} self.original_handlers: Dict[str, List[logging.Handler]] = {}
def setup_logging(self, config: Dict[str, Any]) -> None: def setup_logging(self, config: Dict[str, Any]) -> None:
if self.configured: if self.configured:
return return
level = config.get('level', 'INFO').upper() level = config.get('level', 'INFO').upper()
console_output = config.get('console_output', True) console_output = config.get('console_output', True)
log_file = config.get('log_file', './logs/pyserve.log') log_file = config.get('log_file', './logs/pyserve.log')
@ -91,97 +91,97 @@ class PyServeLogManager:
self._clear_all_handlers() self._clear_all_handlers()
root_logger = logging.getLogger() root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG) root_logger.setLevel(logging.DEBUG)
detailed_formatter = PyServeFormatter( detailed_formatter = PyServeFormatter(
use_colors=False, use_colors=False,
show_module=True, show_module=True,
fmt='%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s' fmt='%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s'
) )
console_formatter = PyServeFormatter( console_formatter = PyServeFormatter(
use_colors=True, use_colors=True,
show_module=True, show_module=True,
fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s' fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
) )
if console_output: if console_output:
console_handler = logging.StreamHandler(sys.stdout) console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(getattr(logging, level)) console_handler.setLevel(getattr(logging, level))
console_handler.setFormatter(console_formatter) console_handler.setFormatter(console_formatter)
console_handler.addFilter(UvicornLogFilter()) console_handler.addFilter(UvicornLogFilter())
root_logger.addHandler(console_handler) root_logger.addHandler(console_handler)
self.handlers['console'] = console_handler self.handlers['console'] = console_handler
if log_file: if log_file:
self._ensure_log_directory(log_file) self._ensure_log_directory(log_file)
file_handler = logging.handlers.RotatingFileHandler( file_handler = logging.handlers.RotatingFileHandler(
log_file, log_file,
maxBytes=10*1024*1024, # 10MB maxBytes=10 * 1024 * 1024, # 10MB
backupCount=5, backupCount=5,
encoding='utf-8' encoding='utf-8'
) )
file_handler.setLevel(logging.DEBUG) file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(detailed_formatter) file_handler.setFormatter(detailed_formatter)
file_handler.addFilter(UvicornLogFilter()) file_handler.addFilter(UvicornLogFilter())
root_logger.addHandler(file_handler) root_logger.addHandler(file_handler)
self.handlers['file'] = file_handler self.handlers['file'] = file_handler
self._configure_library_loggers(level) self._configure_library_loggers(level)
self._intercept_uvicorn_logging() self._intercept_uvicorn_logging()
pyserve_logger = logging.getLogger('pyserve') pyserve_logger = logging.getLogger('pyserve')
pyserve_logger.setLevel(getattr(logging, level)) pyserve_logger.setLevel(getattr(logging, level))
self.loggers['pyserve'] = pyserve_logger self.loggers['pyserve'] = pyserve_logger
pyserve_logger.info(f"PyServe v{__version__} - Система логирования инициализирована") pyserve_logger.info(f"PyServe v{__version__} - Система логирования инициализирована")
pyserve_logger.info(f"Уровень логирования: {level}") pyserve_logger.info(f"Уровень логирования: {level}")
pyserve_logger.info(f"Консольный вывод: {'включен' if console_output else 'отключен'}") pyserve_logger.info(f"Консольный вывод: {'включен' if console_output else 'отключен'}")
pyserve_logger.info(f"Файл логов: {log_file if log_file else 'отключен'}") pyserve_logger.info(f"Файл логов: {log_file if log_file else 'отключен'}")
self.configured = True self.configured = True
def _save_original_handlers(self) -> None: def _save_original_handlers(self) -> None:
logger_names = ['', 'uvicorn', 'uvicorn.access', 'uvicorn.error', 'starlette'] logger_names = ['', 'uvicorn', 'uvicorn.access', 'uvicorn.error', 'starlette']
for name in logger_names: for name in logger_names:
logger = logging.getLogger(name) logger = logging.getLogger(name)
self.original_handlers[name] = logger.handlers.copy() self.original_handlers[name] = logger.handlers.copy()
def _clear_all_handlers(self) -> None: def _clear_all_handlers(self) -> None:
root_logger = logging.getLogger() root_logger = logging.getLogger()
for handler in root_logger.handlers[:]: for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler) root_logger.removeHandler(handler)
handler.close() handler.close()
logger_names = ['uvicorn', 'uvicorn.access', 'uvicorn.error', 'starlette'] logger_names = ['uvicorn', 'uvicorn.access', 'uvicorn.error', 'starlette']
for name in logger_names: for name in logger_names:
logger = logging.getLogger(name) logger = logging.getLogger(name)
for handler in logger.handlers[:]: for handler in logger.handlers[:]:
logger.removeHandler(handler) logger.removeHandler(handler)
handler.close() handler.close()
self.handlers.clear() self.handlers.clear()
def _ensure_log_directory(self, log_file: str) -> None: def _ensure_log_directory(self, log_file: str) -> None:
log_dir = Path(log_file).parent log_dir = Path(log_file).parent
log_dir.mkdir(parents=True, exist_ok=True) log_dir.mkdir(parents=True, exist_ok=True)
def _configure_library_loggers(self, main_level: str) -> None: def _configure_library_loggers(self, main_level: str) -> None:
library_configs = { library_configs = {
# Uvicorn и связанные - только в DEBUG режиме # Uvicorn и связанные - только в DEBUG режиме
'uvicorn': 'DEBUG' if main_level == 'DEBUG' else 'WARNING', 'uvicorn': 'DEBUG' if main_level == 'DEBUG' else 'WARNING',
'uvicorn.access': 'DEBUG' if main_level == 'DEBUG' else 'WARNING', 'uvicorn.access': 'DEBUG' if main_level == 'DEBUG' else 'WARNING',
'uvicorn.error': 'DEBUG' if main_level == 'DEBUG' else 'ERROR', 'uvicorn.error': 'DEBUG' if main_level == 'DEBUG' else 'ERROR',
'uvicorn.asgi': 'DEBUG' if main_level == 'DEBUG' else 'WARNING', 'uvicorn.asgi': 'DEBUG' if main_level == 'DEBUG' else 'WARNING',
# Starlette - только в DEBUG режиме # Starlette - только в DEBUG режиме
'starlette': 'DEBUG' if main_level == 'DEBUG' else 'WARNING', 'starlette': 'DEBUG' if main_level == 'DEBUG' else 'WARNING',
'asyncio': 'WARNING', 'asyncio': 'WARNING',
'concurrent.futures': 'WARNING', 'concurrent.futures': 'WARNING',
'multiprocessing': 'WARNING', 'multiprocessing': 'WARNING',
@ -191,74 +191,75 @@ class PyServeLogManager:
'pyserve.extensions': main_level, 'pyserve.extensions': main_level,
'pyserve.config': main_level, 'pyserve.config': main_level,
} }
for logger_name, level in library_configs.items(): for logger_name, level in library_configs.items():
logger = logging.getLogger(logger_name) logger = logging.getLogger(logger_name)
logger.setLevel(getattr(logging, level)) logger.setLevel(getattr(logging, level))
if logger_name.startswith('uvicorn') and logger_name != 'uvicorn': if logger_name.startswith('uvicorn') and logger_name != 'uvicorn':
logger.propagate = False logger.propagate = False
self.loggers[logger_name] = logger self.loggers[logger_name] = logger
def _intercept_uvicorn_logging(self) -> None: def _intercept_uvicorn_logging(self) -> None:
uvicorn_logger = logging.getLogger('uvicorn') uvicorn_logger = logging.getLogger('uvicorn')
uvicorn_access_logger = logging.getLogger('uvicorn.access') uvicorn_access_logger = logging.getLogger('uvicorn.access')
for handler in uvicorn_logger.handlers[:]: for handler in uvicorn_logger.handlers[:]:
uvicorn_logger.removeHandler(handler) uvicorn_logger.removeHandler(handler)
for handler in uvicorn_access_logger.handlers[:]: for handler in uvicorn_access_logger.handlers[:]:
uvicorn_access_logger.removeHandler(handler) uvicorn_access_logger.removeHandler(handler)
uvicorn_logger.propagate = True uvicorn_logger.propagate = True
uvicorn_access_logger.propagate = True uvicorn_access_logger.propagate = True
def get_logger(self, name: str) -> logging.Logger: def get_logger(self, name: str) -> logging.Logger:
if name not in self.loggers: if name not in self.loggers:
logger = logging.getLogger(name) logger = logging.getLogger(name)
self.loggers[name] = logger self.loggers[name] = logger
return self.loggers[name] return self.loggers[name]
def set_level(self, logger_name: str, level: str) -> None: def set_level(self, logger_name: str, level: str) -> None:
if logger_name in self.loggers: if logger_name in self.loggers:
self.loggers[logger_name].setLevel(getattr(logging, level.upper())) self.loggers[logger_name].setLevel(getattr(logging, level.upper()))
def add_handler(self, name: str, handler: logging.Handler) -> None: def add_handler(self, name: str, handler: logging.Handler) -> None:
if name not in self.handlers: if name not in self.handlers:
root_logger = logging.getLogger() root_logger = logging.getLogger()
root_logger.addHandler(handler) root_logger.addHandler(handler)
self.handlers[name] = handler self.handlers[name] = handler
def remove_handler(self, name: str) -> None: def remove_handler(self, name: str) -> None:
if name in self.handlers: if name in self.handlers:
root_logger = logging.getLogger() root_logger = logging.getLogger()
root_logger.removeHandler(self.handlers[name]) root_logger.removeHandler(self.handlers[name])
self.handlers[name].close() self.handlers[name].close()
del self.handlers[name] del self.handlers[name]
def create_access_log(self, method: str, path: str, status_code: int, def create_access_log(self, method: str, path: str, status_code: int,
response_time: float, client_ip: str, user_agent: str = "") -> None: response_time: float, client_ip: str, user_agent: str = "") -> None:
access_logger = self.get_logger('pyserve.access') access_logger = self.get_logger('pyserve.access')
log_message = f'{client_ip} - - [{time.strftime("%d/%b/%Y:%H:%M:%S %z")}] ' \ log_message = f'{client_ip} - - [{time.strftime("%d/%b/%Y:%H:%M:%S %z")}] ' \
f'"{method} {path} HTTP/1.1" {status_code} - ' \ f'"{method} {path} HTTP/1.1" {status_code} - ' \
f'"{user_agent}" {response_time:.3f}s' f'"{user_agent}" {response_time:.3f}s'
access_logger.info(log_message) access_logger.info(log_message)
def shutdown(self) -> None: def shutdown(self) -> None:
for handler in self.handlers.values(): for handler in self.handlers.values():
handler.close() handler.close()
self.handlers.clear() self.handlers.clear()
for logger_name, handlers in self.original_handlers.items(): for logger_name, handlers in self.original_handlers.items():
logger = logging.getLogger(logger_name) logger = logging.getLogger(logger_name)
for handler in handlers: for handler in handlers:
logger.addHandler(handler) logger.addHandler(handler)
self.loggers.clear() self.loggers.clear()
self.configured = False self.configured = False
log_manager = PyServeLogManager() log_manager = PyServeLogManager()
@ -270,8 +271,8 @@ def get_logger(name: str) -> logging.Logger:
return log_manager.get_logger(name) return log_manager.get_logger(name)
def create_access_log(method: str, path: str, status_code: int, def create_access_log(method: str, path: str, status_code: int,
response_time: float, client_ip: str, user_agent: str = "") -> None: response_time: float, client_ip: str, user_agent: str = "") -> None:
log_manager.create_access_log(method, path, status_code, response_time, client_ip, user_agent) log_manager.create_access_log(method, path, status_code, response_time, client_ip, user_agent)

View File

@ -24,12 +24,12 @@ class Router:
if pattern.startswith("="): if pattern.startswith("="):
exact_path = pattern[1:] exact_path = pattern[1:]
self.exact_routes[exact_path] = config self.exact_routes[exact_path] = config
logger.debug(f"Добавлен exact маршрут: {exact_path}") logger.debug(f"Added exact route: {exact_path}")
return return
if pattern == "__default__": if pattern == "__default__":
self.default_route = config self.default_route = config
logger.debug("Добавлен default маршрут") logger.debug("Added default route")
return return
if pattern.startswith("~"): if pattern.startswith("~"):
@ -40,9 +40,9 @@ class Router:
try: try:
compiled_pattern = re.compile(regex_pattern, flags) compiled_pattern = re.compile(regex_pattern, flags)
self.routes[compiled_pattern] = config self.routes[compiled_pattern] = config
logger.debug(f"Добавлен regex маршрут: {pattern}") logger.debug(f"Added regex route: {pattern}")
except re.error as e: except re.error as e:
logger.error(f"Ошибка компиляции regex {pattern}: {e}") logger.error(f"Regex compilation error {pattern}: {e}")
def match(self, path: str) -> Optional[RouteMatch]: def match(self, path: str) -> Optional[RouteMatch]:
if path in self.exact_routes: if path in self.exact_routes:
@ -77,7 +77,7 @@ class RequestHandler:
try: try:
return await self._process_route(request, route_match) return await self._process_route(request, route_match)
except Exception as e: except Exception as e:
logger.error(f"Ошибка обработки запроса {path}: {e}") logger.error(f"Request processing error {path}: {e}")
return PlainTextResponse("500 Internal Server Error", status_code=500) return PlainTextResponse("500 Internal Server Error", status_code=500)
async def _process_route(self, request: Request, route_match: RouteMatch) -> Response: async def _process_route(self, request: Request, route_match: RouteMatch) -> Response:
@ -169,7 +169,7 @@ class RequestHandler:
for key, value in params.items(): for key, value in params.items():
proxy_url = proxy_url.replace(f"{{{key}}}", value) proxy_url = proxy_url.replace(f"{{{key}}}", value)
logger.info(f"Проксирование запроса на: {proxy_url}") logger.info(f"Proxying request to: {proxy_url}")
return PlainTextResponse(f"Proxy to: {proxy_url}", status_code=200) return PlainTextResponse(f"Proxy to: {proxy_url}", status_code=200)

View File

@ -89,8 +89,8 @@ class PyServeServer:
ext_metrics = getattr(extension, 'get_metrics')() ext_metrics = getattr(extension, 'get_metrics')()
metrics.update(ext_metrics) metrics.update(ext_metrics)
except Exception as e: except Exception as e:
logger.error(f"Ошибка получения метрик от {type(extension).__name__}: {e}") logger.error(f"Error getting metrics from {type(extension).__name__}: {e}")
import json import json
return Response( return Response(
json.dumps(metrics, ensure_ascii=False, indent=2), json.dumps(metrics, ensure_ascii=False, indent=2),
@ -105,11 +105,11 @@ class PyServeServer:
return None return None
if not Path(self.config.ssl.cert_file).exists(): if not Path(self.config.ssl.cert_file).exists():
logger.error(f"SSL сертификат не найден: {self.config.ssl.cert_file}") logger.error(f"SSL certificate not found: {self.config.ssl.cert_file}")
return None return None
if not Path(self.config.ssl.key_file).exists(): if not Path(self.config.ssl.key_file).exists():
logger.error(f"SSL ключ не найден: {self.config.ssl.key_file}") logger.error(f"SSL key not found: {self.config.ssl.key_file}")
return None return None
try: try:
@ -118,22 +118,18 @@ class PyServeServer:
self.config.ssl.cert_file, self.config.ssl.cert_file,
self.config.ssl.key_file self.config.ssl.key_file
) )
logger.info("SSL контекст создан успешно") logger.info("SSL context created successfully")
return context return context
except Exception as e: except Exception as e:
logger.error(f"Ошибка создания SSL контекста: {e}") logger.error(f"Error creating SSL context: {e}")
return None return None
def run(self) -> None: def run(self) -> None:
"""Запуск сервера"""
if not self.config.validate(): if not self.config.validate():
logger.error("Конфигурация невалидна, сервер не может быть запущен") logger.error("Configuration is invalid, server cannot be started")
return return
# Создаем директории если их нет
self._ensure_directories() self._ensure_directories()
# SSL конфигурация
ssl_context = self._create_ssl_context() ssl_context = self._create_ssl_context()
uvicorn_config = { uvicorn_config = {
@ -154,21 +150,21 @@ class PyServeServer:
protocol = "https" protocol = "https"
else: else:
protocol = "http" protocol = "http"
logger.info(f"Запуск PyServe сервера на {protocol}://{self.config.server.host}:{self.config.server.port}") logger.info(f"Starting PyServe server at {protocol}://{self.config.server.host}:{self.config.server.port}")
try: try:
uvicorn.run(**uvicorn_config) uvicorn.run(**uvicorn_config)
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info("Получен сигнал остановки") logger.info("Received shutdown signal")
except Exception as e: except Exception as e:
logger.error(f"Ошибка запуска сервера: {e}") logger.error(f"Error starting server: {e}")
finally: finally:
self.shutdown() self.shutdown()
async def run_async(self) -> None: async def run_async(self) -> None:
if not self.config.validate(): if not self.config.validate():
logger.error("Конфигурация невалидна, сервер не может быть запущен") logger.error("Configuration is invalid, server cannot be started")
return return
self._ensure_directories() self._ensure_directories()
@ -201,17 +197,17 @@ class PyServeServer:
for directory in directories: for directory in directories:
Path(directory).mkdir(parents=True, exist_ok=True) Path(directory).mkdir(parents=True, exist_ok=True)
logger.debug(f"Создана/проверена директория: {directory}") logger.debug(f"Created/checked directory: {directory}")
def shutdown(self) -> None: def shutdown(self) -> None:
logger.info("Завершение работы PyServe сервера") logger.info("Shutting down PyServe server")
self.extension_manager.cleanup() self.extension_manager.cleanup()
from .logging_utils import shutdown_logging from .logging_utils import shutdown_logging
shutdown_logging() shutdown_logging()
logger.info("Сервер остановлен") logger.info("Server stopped")
def add_extension(self, extension_type: str, config: Dict[str, Any]) -> None: def add_extension(self, extension_type: str, config: Dict[str, Any]) -> None:
self.extension_manager.load_extension(extension_type, config) self.extension_manager.load_extension(extension_type, config)
@ -224,8 +220,8 @@ class PyServeServer:
ext_metrics = getattr(extension, 'get_metrics')() ext_metrics = getattr(extension, 'get_metrics')()
metrics.update(ext_metrics) metrics.update(ext_metrics)
except Exception as e: except Exception as e:
logger.error(f"Ошибка получения метрик от {type(extension).__name__}: {e}") logger.error(f"Error getting metrics from {type(extension).__name__}: {e}")
return metrics return metrics

6
pytest.ini Normal file
View File

@ -0,0 +1,6 @@
[tool:pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short

61
run.py
View File

@ -1,63 +1,4 @@
import sys from pyserve.cli import main
import argparse
from pathlib import Path
from pyserve import PyServeServer, Config
def main():
parser = argparse.ArgumentParser(description="PyServe - HTTP веб-сервер")
parser.add_argument(
"-c", "--config",
default="config.yaml",
help="Путь к конфигурационному файлу (по умолчанию: config.yaml)"
)
parser.add_argument(
"--host",
help="Хост для привязки сервера"
)
parser.add_argument(
"--port",
type=int,
help="Порт для привязки сервера"
)
parser.add_argument(
"--debug",
action="store_true",
help="Включить отладочный режим"
)
args = parser.parse_args()
config_path = args.config
if not Path(config_path).exists():
print(f"Конфигурационный файл {config_path} не найден")
print("Используется конфигурация по умолчанию")
config = Config()
else:
try:
config = Config.from_yaml(config_path)
except Exception as e:
print(f"Ошибка загрузки конфигурации: {e}")
sys.exit(1)
if args.host:
config.server.host = args.host
if args.port:
config.server.port = args.port
if args.debug:
config.logging.level = "DEBUG"
server = PyServeServer(config)
try:
server.run()
except KeyboardInterrupt:
print("\nСервер остановлен пользователем")
except Exception as e:
print(f"Ошибка запуска сервера: {e}")
sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

1
tests/__init__.py Normal file
View File

@ -0,0 +1 @@
# Marker file for tests

50
tests/test_cli.py Normal file
View File

@ -0,0 +1,50 @@
import pytest
from unittest.mock import patch, MagicMock
from pyserve.cli import main
def test_cli_version():
with patch('sys.argv', ['pyserve', '--version']):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 0
def test_cli_help():
with patch('sys.argv', ['pyserve', '--help']):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 0
@patch('pyserve.cli.PyServeServer')
@patch('pyserve.cli.Config')
def test_cli_default_config(mock_config, mock_server):
mock_config_instance = MagicMock()
mock_config.return_value = mock_config_instance
mock_server_instance = MagicMock()
mock_server.return_value = mock_server_instance
with patch('sys.argv', ['pyserve']):
with patch('pathlib.Path.exists', return_value=False):
main()
mock_config.assert_called_once()
mock_server.assert_called_once_with(mock_config_instance)
mock_server_instance.run.assert_called_once()
@patch('pyserve.cli.PyServeServer')
@patch('pyserve.cli.Config')
def test_cli_custom_host_port(mock_config, mock_server):
mock_config_instance = MagicMock()
mock_config.return_value = mock_config_instance
mock_server_instance = MagicMock()
mock_server.return_value = mock_server_instance
with patch('sys.argv', ['pyserve', '--host', '127.0.0.1', '--port', '9000']):
with patch('pathlib.Path.exists', return_value=False):
main()
assert mock_config_instance.server.host == '127.0.0.1'
assert mock_config_instance.server.port == 9000