1
0
Fork 0
mirror of https://github.com/EDeev/y.calendarkin.git synced 2026-10-08 00:39:27 +03:00

Зависимости по рабочему окружению, тесты, CI, Docker и лицензия MIT

Тесты разбора календаря (события на сегодня, повторения, UNTIL, проверка ссылки, текст
уведомления). Образ по тегу v* публикуется в GHCR и dcr.deev.su.
This commit is contained in:
Деев Егор Викторович 2026-10-05 19:28:43 +00:00
parent 15bd36580e
commit 73690afd8f
11 changed files with 225 additions and 18 deletions

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
# Токен бота от @BotFather
BOT_TOKEN=123456:your-token

23
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,23 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements-dev.txt
- run: ruff check --select E9,F code tests
- run: pytest -q
- name: Бот импортируется
working-directory: code
env:
BOT_TOKEN: "123456:TEST"
run: python -c "import bot"

42
.github/workflows/docker.yml vendored Normal file
View file

@ -0,0 +1,42 @@
name: Docker
on:
push:
tags: ["v*"]
workflow_dispatch:
jobs:
image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/login-action@v3
with:
registry: dcr.deev.su
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
- id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/edeev/y.calendarkin
dcr.deev.su/edeev/y.calendarkin
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.env
__pycache__/
*.pyc
.pytest_cache/
db/*.db
data/icals/*.ics

20
Dockerfile Normal file
View file

@ -0,0 +1,20 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY code/ code/
COPY data/photo_edit_alarm.jpg data/
RUN useradd --create-home --uid 1000 app \
&& mkdir -p data/icals db \
&& chown -R app:app /app
USER app
VOLUME ["/app/data/icals", "/app/db"]
# пути к данным в коде — относительно папки code/ (../data, ../db)
WORKDIR /app/code
CMD ["python", "bot.py"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Egor Deev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

15
compose.yaml Normal file
View file

@ -0,0 +1,15 @@
services:
bot:
build: .
image: ghcr.io/edeev/y.calendarkin:latest
env_file: .env
environment:
TZ: Europe/Moscow
volumes:
- icals:/app/data/icals
- db:/app/db
restart: unless-stopped
volumes:
icals:
db:

3
requirements-dev.txt Normal file
View file

@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.4.2
ruff==0.14.0

View file

@ -1,18 +1,5 @@
# Core bot framework
aiogram>=3.0.0
# Calendar processing
icalendar>=5.0.0
python-dateutil>=2.8.0
# Timezone support
pytz>=2023.3
# HTTP client for downloading calendar files
wget>=3.2
# Database (built-in with Python)
# sqlite3 - included in Python standard library
# Additional dependencies for date/time handling
typing-extensions>=4.0.0
aiogram==3.23.0
aiohttp==3.13.2
icalendar==6.3.2
python-dateutil==2.9.0.post0
pytz==2025.2

4
tests/conftest.py Normal file
View file

@ -0,0 +1,4 @@
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "code"))

84
tests/test_script.py Normal file
View file

@ -0,0 +1,84 @@
from datetime import datetime, timedelta
import pytz
import script
TZ = pytz.timezone("Europe/Moscow")
def make_ics(events):
body = "\r\n".join(events)
return ("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n" + body + "\r\nEND:VCALENDAR\r\n").encode()
def vevent(uid, start, end, summary, extra=""):
return (f"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTART;TZID=Europe/Moscow:{start:%Y%m%dT%H%M%S}\r\n"
f"DTEND;TZID=Europe/Moscow:{end:%Y%m%dT%H%M%S}\r\nSUMMARY:{summary}\r\n{extra}END:VEVENT")
def test_parse_ical_url_accepts_only_yandex_export():
ok = "https://calendar.yandex.ru/export/ics.xml?private_token=abc&tz_id=Asia/Krasnoyarsk"
assert script.parse_ical_url(ok) == "Asia/Krasnoyarsk"
assert script.parse_ical_url("https://calendar.yandex.ru/export/ics.xml?private_token=abc") == "Europe/Moscow"
assert script.parse_ical_url("https://calendar.yandex.ru/x?tz_id=Not/AZone") == "Europe/Moscow"
for bad in ("http://calendar.yandex.ru/export/ics.xml", "https://127.0.0.1/ics", "file:///etc/passwd",
"https://calendar.yandex.ru.evil.com/ics", "https://example.com/?tz_id=Europe/Moscow"):
assert script.parse_ical_url(bad) is None, bad
def test_text_ical_today_events_and_recurrence(tmp_path, monkeypatch):
monkeypatch.setattr(script, "ICAL_DIR", str(tmp_path))
now = datetime.now(TZ).replace(tzinfo=None, second=0, microsecond=0)
today = now.replace(hour=10, minute=0)
events = [
vevent("once", today, today + timedelta(hours=1), "Разовое <важное> & срочное",
"DESCRIPTION:Описание\r\nORGANIZER:mailto:boss@example.com\r\n"),
vevent("weekly", today.replace(hour=12) - timedelta(weeks=3),
today.replace(hour=13) - timedelta(weeks=3), "Еженедельное", "RRULE:FREQ=WEEKLY\r\n"),
vevent("tomorrow", today + timedelta(days=1), today + timedelta(days=1, hours=1), "Завтра"),
]
(tmp_path / "1.ics").write_bytes(make_ics(events))
found = {e[3]["name"]: e for e in script.text_ical(1, TZ)}
assert set(found) == {"Разовое <важное> & срочное", "Еженедельное"}
assert found["Еженедельное"][1].hour == 12
text = script.message_form(1, found["Разовое <важное> & срочное"][3])
assert "&lt;важное&gt; &amp; срочное" in text
assert "boss@example.com" in text and "mailto:" not in text
assert "10:00 — 11:00" in text and str(now.year) not in text
def test_message_form_hides_empty_fields():
event = {"name": "Встреча", "desc": "", "org": "", "all_day": False,
"datetime": [datetime(2026, 1, 1, 9, 0), datetime(2026, 1, 1, 9, 30)]}
text = script.message_form(0, event)
assert "Описание" not in text and "Организатор" not in text
assert "09:00 — 09:30" in text
def test_missing_calendar_file_gives_no_events(tmp_path, monkeypatch):
monkeypatch.setattr(script, "ICAL_DIR", str(tmp_path))
assert script.text_ical(42, TZ) == []
def test_until_not_in_utc(tmp_path, monkeypatch):
# повторяющиеся события с UNTIL местным временем или датой раньше роняли разбор календаря
monkeypatch.setattr(script, "ICAL_DIR", str(tmp_path))
now = datetime.now(TZ).replace(tzinfo=None, second=0, microsecond=0)
first = now.replace(hour=9, minute=0) - timedelta(weeks=5)
events = [
vevent("ended-local", first, first + timedelta(hours=1), "Закончилось",
f"RRULE:FREQ=WEEKLY;UNTIL={first + timedelta(weeks=2):%Y%m%dT%H%M%S}\r\n"),
vevent("ended-date", first, first + timedelta(hours=1), "Закончилось датой",
f"RRULE:FREQ=WEEKLY;UNTIL={first + timedelta(weeks=2):%Y%m%d}\r\n"),
vevent("running", first, first + timedelta(hours=1), "Идёт",
f"RRULE:FREQ=WEEKLY;UNTIL={now + timedelta(weeks=4):%Y%m%dT%H%M%S}\r\n"),
]
(tmp_path / "2.ics").write_bytes(make_ics(events))
names = {e[3]["name"] for e in script.text_ical(2, TZ)}
assert names == {"Идёт"}
assert script.until_utc("20260101", TZ) == "20260101T205959Z"