Inject vibecode in my veins

This commit is contained in:
t0xa 2026-10-22 23:46:29 +03:00
parent 6bc354d701
commit fc192a4a0b
63 changed files with 54802 additions and 1277 deletions

View file

@ -35,7 +35,6 @@ ENV/
Thumbs.db Thumbs.db
# Project specific # Project specific
uv.lock
.mypy_cache .mypy_cache
.opencode .opencode

View file

@ -12,34 +12,45 @@ The application uses a layered architecture with parsers, models, services, and
## Development Commands ## Development Commands
### Local Development The project is developed and run **only in Docker**: there is no local Python setup. Everything below runs inside the `fastapi-app` container (`.env` must have `POSTGRES_DATABASE_HOST=postgres`, see `.env.example`).
```bash
# Start development server
python run.py
# Start with Docker Compose (includes PostgreSQL) ### Docker Development
```bash
# Start the app + PostgreSQL (applies migrations on start, hot reload for app/)
docker compose up --build docker compose up --build
# Run migrations manually # Apply migrations manually (Alembic; the app itself does NOT migrate on startup)
python -c "from migrations.runner import MigrationRunner; from app.config import settings; import asyncio; asyncio.run(MigrationRunner(settings.get_postgres_database_url()).run_migrations())" docker compose exec fastapi-app alembic upgrade head
# Create a migration after changing models in app/core/database/models/
# (-u keeps the generated file owned by you instead of root)
docker compose exec -u "$(id -u):$(id -g)" fastapi-app alembic revision --autogenerate -m "describe change"
# Import all Obsidian + Apple notes into the database
docker compose exec fastapi-app python -m app.cli import [--replace]
# Stop (keeps the data) / stop and drop the database volume
docker compose down
docker compose down -v
``` ```
Rebuild (`--build`) is needed only after changing `requirements*.txt`, `Dockerfile` or `pyproject.toml`.
### Testing ### Testing
Tests that need a database use a separate `<POSTGRES_DATABASE_DB>_test` database (`TEST_DATABASE_URL`, created automatically). Without it they are skipped.
```bash ```bash
# Run tests # Run all tests
pytest docker compose exec fastapi-app pytest
# Run specific test file # Run specific test file
pytest tests/test_core/test_obsidian_parser.py docker compose exec fastapi-app pytest tests/test_core/test_obsidian_parser.py
# Run tests with coverage # Without a running stack (starts postgres, runs once, removes the container)
pytest --cov=app docker compose run --rm fastapi-app pytest
``` ```
### Type Checking ### Type Checking
```bash ```bash
# Run type checking docker compose exec fastapi-app mypy app/
mypy app/
``` ```
## Architecture ## Architecture
@ -64,7 +75,7 @@ mypy app/
### Database ### Database
The application supports both SQLite (default) and PostgreSQL. Database migrations are handled by a custom migration runner in `migrations/runner.py` that reads SQL files from `migrations/sql/`. The application uses PostgreSQL through async SQLAlchemy (asyncpg). ORM models live in `app/core/database/models/`, `TrainingRepository` (`app/core/database/repository.py`) is the data access layer, and a session is injected into endpoints with `Depends(get_session)`. Schema migrations are handled by Alembic (`alembic.ini`, `migrations/`); the URL is taken from `app/config.py`.
Configuration is managed through `app/config.py` using Pydantic settings with `.env` file support. Configuration is managed through `app/config.py` using Pydantic settings with `.env` file support.
@ -72,8 +83,8 @@ Configuration is managed through `app/config.py` using Pydantic settings with `.
- `app/main.py` - FastAPI application setup with lifespan management - `app/main.py` - FastAPI application setup with lifespan management
- `app/config.py` - Application configuration and database settings - `app/config.py` - Application configuration and database settings
- `run.py` - Development server entry point - `run.py` - Standalone uvicorn runner (unused: Docker starts uvicorn from `compose.yaml`)
- `migrations/runner.py` - Custom database migration system - `migrations/` - Alembic environment and versions
- `compose.yaml` - Docker Compose setup with PostgreSQL - `compose.yaml` - Docker Compose setup with PostgreSQL
## Environment Setup ## Environment Setup

View file

@ -8,14 +8,17 @@ RUN apt-get update && apt-get install -y \
build-essential \ build-essential \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy pyproject.toml first for better caching # Copy requirements first for better caching
COPY pyproject.toml ./ COPY requirements.txt requirements-dev.txt ./
# Install Python dependencies # Install Python dependencies (the project is run only in Docker, so dev tools
RUN pip install --no-cache-dir -e . # like pytest and mypy are part of the image)
RUN pip install --no-cache-dir -r requirements-dev.txt
# Copy application code # Copy application code
COPY pyproject.toml alembic.ini ./
COPY ./app ./app COPY ./app ./app
COPY ./migrations ./migrations
COPY ./data ./data COPY ./data ./data
COPY ./tests ./tests COPY ./tests ./tests

149
alembic.ini Normal file
View file

@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

11
app/api/deps.py Normal file
View file

@ -0,0 +1,11 @@
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database.connection import get_session
from app.core.database.repository import TrainingRepository
def get_training_repository(
session: AsyncSession = Depends(get_session),
) -> TrainingRepository:
return TrainingRepository(session)

View file

@ -1,17 +1,13 @@
from datetime import datetime from typing import List
import logging
from fastapi import APIRouter
from app.core.database.connection import SQLiteExecutor from fastapi import APIRouter, Depends, HTTPException
from app.core.database.models.approach import ApproachOps
from app.core.database.models.training import TrainingOps from app.api.deps import get_training_repository
from app.core.database.models.exercise import ExerciseOps from app.core.database.repository import TrainingRepository
from app.core.dto.training import TrainingDTO from app.core.dto.training import TrainingDTO
from app.core.parsers.obsidian import parse_training_data from app.core.parsers.obsidian import parse_training_data
sqllite_executor = SQLiteExecutor()
router = APIRouter() router = APIRouter()
@ -20,43 +16,19 @@ async def obsidian_trainings_list():
return {"data": parse_training_data()} return {"data": parse_training_data()}
@router.get("/test/create/") @router.get("/", response_model=List[TrainingDTO])
async def create_sample_training(): async def get_trainings_list(
test_training = TrainingDTO(date=datetime.now().date(), trainer="Stepka") repository: TrainingRepository = Depends(get_training_repository),
training_executor = TrainingOps(sqllite_executor) ):
result_id = await training_executor.create(test_training) return await repository.list()
return {"status": "ok", "create_id": result_id}
@router.get("/test/get/list/") @router.delete("/{training_id}/")
async def get_trainings_list(): async def delete_training(
training_executor = TrainingOps(sqllite_executor) training_id: int,
results = await training_executor.list() repository: TrainingRepository = Depends(get_training_repository),
return {"status": "ok", "create_id": results} ):
deleted = await repository.delete([training_id])
if not deleted:
@router.delete("/test/delete/") raise HTTPException(status_code=404, detail="Training not found")
async def delete_by_ids():
training_executor = TrainingOps(sqllite_executor)
await training_executor.delete(list_of_ids=[4,5,6])
return {"status": "ok"}
@router.post("/test/create/")
async def create_full_training():
training_executor = TrainingOps(sqllite_executor)
exercise_executor = ExerciseOps(sqllite_executor)
approach_executor = ApproachOps(sqllite_executor)
sample_training_data: TrainingDTO = parse_training_data()[-1]
logging.info(sample_training_data)
training_id: int = await training_executor.create(sample_training_data)
if sample_training_data.exercises:
for exercise in sample_training_data.exercises:
exercise.training_id = training_id
exercise_id: int = await exercise_executor.create(exercise)
if exercise.approaches:
for approach in exercise.approaches:
approach.exercise_id = exercise_id
await approach_executor.create(approach)
return {"status": "ok"} return {"status": "ok"}

View file

@ -1,8 +1,14 @@
import calendar
import logging import logging
from fastapi import APIRouter, Request, Form from typing import Optional
from fastapi import APIRouter, Depends, Request, Form
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from app.api.deps import get_training_repository
from app.core.database.repository import TrainingRepository
from app.core.metrics import ExerciseProgress, summarize
from app.core.parsers.obsidian import ObsidianNotesParser, parse_training_data from app.core.parsers.obsidian import ObsidianNotesParser, parse_training_data
from app.core.templates.templates import BaseTemplateResponse from app.core.templates.templates import BaseTemplateResponse
@ -11,13 +17,84 @@ templates = Jinja2Templates(directory="app/templates")
template_renderer = BaseTemplateResponse(templates) template_renderer = BaseTemplateResponse(templates)
def build_progress_chart(progress: ExerciseProgress) -> dict:
"""Data for the progress chart: two series over time, x is a UTC timestamp in ms."""
if progress.weighted:
series = [
("e1RM, кг", lambda metrics: metrics.e1rm),
("Тоннаж, кг", lambda metrics: metrics.volume or None),
]
else:
series = [
("Лучший подход, повторы", lambda metrics: metrics.best_reps),
("Всего повторов за тренировку", lambda metrics: metrics.total_reps),
]
chronological = list(reversed(progress.rows))
return {
"series": [
{
"label": label,
"points": [
[calendar.timegm(row.session.date.timetuple()) * 1000, round(value, 1)]
for row in chronological
if (value := metric(row.metrics)) is not None
],
}
for label, metric in series
]
}
@web_router.get("/", response_class=HTMLResponse) @web_router.get("/", response_class=HTMLResponse)
async def home(request: Request): async def home(
"""Home page""" request: Request,
repository: TrainingRepository = Depends(get_training_repository),
):
"""Home page: all trainings from the database in a single table"""
return template_renderer.render( return template_renderer.render(
request, request,
"index.html", "index.html",
context={"trainings": await repository.list()},
)
@web_router.get("/exercises/", response_class=HTMLResponse)
async def exercises(
request: Request,
name: Optional[str] = None,
repository: TrainingRepository = Depends(get_training_repository),
):
"""Exercise search box and the whole history of the chosen exercise"""
query = (name or "").strip()
exercise_names = await repository.list_exercise_names()
# Exact name, but case-insensitive: what was typed may differ in case
names_by_casefold = {item.name.casefold(): item.name for item in exercise_names}
selected = names_by_casefold.get(query.casefold())
history = await repository.exercise_history(selected) if selected else []
# Nothing matched exactly: offer the names containing the typed text
suggestions = (
[item for item in exercise_names if query.casefold() in item.name.casefold()]
if query and not selected
else []
)
progress = summarize(history) if history else None
return template_renderer.render(
request,
"exercises.html",
context={
"exercise_names": exercise_names,
"query": query,
"selected": selected,
"suggestions": suggestions,
"progress": progress,
"chart": build_progress_chart(progress) if progress else None,
},
) )

64
app/cli.py Normal file
View file

@ -0,0 +1,64 @@
"""Command line helpers.
python -m app.cli import [--replace]
Imports all trainings from the Obsidian and Apple notes into the database.
The schema must already exist (`alembic upgrade head`).
"""
import argparse
import asyncio
import logging
from app.config import settings
from app.core.database.connection import create_engine, create_session_factory
from app.core.database.repository import TrainingRepository
from app.core.parsers.apple import AppleNotesParser
from app.core.parsers.obsidian import ObsidianNotesParser
async def import_notes(replace: bool) -> None:
parsers = [ObsidianNotesParser(), AppleNotesParser()]
trainings = []
for parser in parsers:
parsed = parser.parse_file()
print(
f"{type(parser).__name__}: {len(parsed)} trainings, "
f"{len(parser.errors)} skipped lines"
)
trainings.extend(parsed)
engine = create_engine(settings.get_postgres_database_url())
try:
async with create_session_factory(engine)() as session:
repository = TrainingRepository(session)
existing = await repository.count()
if existing and not replace:
raise SystemExit(
f"Database already has {existing} trainings, "
"use --replace to overwrite them"
)
if replace:
await repository.delete_all()
await repository.save_many(trainings)
finally:
await engine.dispose()
print(f"Imported {len(trainings)} trainings")
def main() -> None:
logging.basicConfig(level=logging.WARNING)
arg_parser = argparse.ArgumentParser(prog="app.cli")
subcommands = arg_parser.add_subparsers(dest="command", required=True)
import_parser = subcommands.add_parser("import", help="import notes into the DB")
import_parser.add_argument(
"--replace", action="store_true", help="delete existing trainings first"
)
args = arg_parser.parse_args()
if args.command == "import":
asyncio.run(import_notes(replace=args.replace))
if __name__ == "__main__":
main()

View file

@ -1,17 +1,16 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional from typing import Optional
from urllib.parse import quote
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
# Base app settings # Base app settings
model_config = SettingsConfigDict(env_file=".env") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "F1tness Parser API" app_name: str = "F1tness Parser API"
version: str = "0.0.1" version: str = "0.0.1"
debug: bool = False debug: bool = False
# SQLite database settings
SQLITE_DATABASE_PATH: str = "fitness.db"
# Postgres database settings # Postgres database settings
POSTGRES_DATABASE_URL: Optional[str] = None POSTGRES_DATABASE_URL: Optional[str] = None
POSTGRES_DATABASE_HOST: str = "localhost" POSTGRES_DATABASE_HOST: str = "localhost"
@ -20,16 +19,15 @@ class Settings(BaseSettings):
POSTGRES_DATABASE_USER: str = "postgres" POSTGRES_DATABASE_USER: str = "postgres"
POSTGRES_DATABASE_PASSWORD: str = "password" POSTGRES_DATABASE_PASSWORD: str = "password"
def get_postgres_database_url(self, async_driver: bool = True) -> str: def get_postgres_database_url(self) -> str:
"""Method for receiving relational database URL""" """SQLAlchemy async URL. POSTGRES_DATABASE_URL, if set, takes precedence."""
# If POSTGRES_DATABASE_URL is somehow set in env file - return it
if self.POSTGRES_DATABASE_URL: if self.POSTGRES_DATABASE_URL:
return self.POSTGRES_DATABASE_URL return self.POSTGRES_DATABASE_URL
driver = "postgresql+asyncpg" if async_driver else "postgresql"
return ( return (
f"{driver}://{self.POSTGRES_DATABASE_USER}:{self.POSTGRES_DATABASE_PASSWORD}" f"postgresql+asyncpg://{quote(self.POSTGRES_DATABASE_USER)}"
f":{quote(self.POSTGRES_DATABASE_PASSWORD)}"
f"@{self.POSTGRES_DATABASE_HOST}" f"@{self.POSTGRES_DATABASE_HOST}"
f":{self.POSTGRES_DATABASE_PORT}/{self.POSTGRES_DATABASE_DB}" f":{self.POSTGRES_DATABASE_PORT}/{self.POSTGRES_DATABASE_DB}"
) )

View file

@ -1,36 +1,24 @@
from typing import Dict, List, Any from typing import AsyncIterator
import aiosqlite
from app.config import settings from fastapi import Request
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
class SQLiteExecutor: def create_engine(database_url: str) -> AsyncEngine:
"""Executor для SQL запросов в БД SQLlite""" return create_async_engine(database_url)
def __init__(self) -> None:
self._db_path = settings.SQLITE_DATABASE_PATH
async def execute_query(
self, query: str, params: tuple = ()
) -> List[Dict[str, Any]]:
"""Выполнить запрос, который не должен вносить изменения в БД.
:param query: SQL запрос def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
:param params: Параметры, которые нужно передать вместе с запросом # expire_on_commit=False keeps ids/attributes readable after commit
:return: Список, выдаваемый aiosqlite return async_sessionmaker(engine, expire_on_commit=False)
"""
async with aiosqlite.connect(self._db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(query, params)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
async def execute_mod_query(self, query: str, params: tuple = ()) -> int:
"""Выполнить запрос, который что-то изменяет в БД.
:param query: SQL запрос async def get_session(request: Request) -> AsyncIterator[AsyncSession]:
:param params: Параметры, которые нужно передать вместе с запросом """FastAPI dependency: one session per request, factory is set in the app lifespan."""
:return: Список, выдаваемый aiosqlite async with request.app.state.session_factory() as session:
""" yield session
async with aiosqlite.connect(self._db_path) as db:
cursor = await db.execute(query, params)
await db.commit()
return cursor.lastrowid if cursor.lastrowid else cursor.rowcount

View file

@ -0,0 +1,6 @@
from app.core.database.models.approach import ApproachModel
from app.core.database.models.base import Base
from app.core.database.models.exercise import ExerciseModel
from app.core.database.models.training import TrainingModel
__all__ = ["ApproachModel", "Base", "ExerciseModel", "TrainingModel"]

View file

@ -1,26 +1,26 @@
from typing import List, Dict, Any from datetime import datetime
from app.core.database.connection import SQLiteExecutor from typing import TYPE_CHECKING
from app.core.dto.training import ApproachDTO
from sqlalchemy import DateTime, Float, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database.models.base import Base
if TYPE_CHECKING:
from app.core.database.models.exercise import ExerciseModel
class ApproachOps: class ApproachModel(Base):
TABLE_NAME: str = "approaches" __tablename__ = "approaches"
def __init__(self, executor: SQLiteExecutor) -> None: id: Mapped[int] = mapped_column(primary_key=True)
self._executor = executor exercise_id: Mapped[int] = mapped_column(
ForeignKey("exercises.id", ondelete="CASCADE"), index=True
async def create(self, approach: ApproachDTO) -> int: )
query = f"INSERT INTO {self.TABLE_NAME} (exercise_id, weight, reps) VALUES (?, ?, ?)" weight: Mapped[float] = mapped_column(Float)
return await self._executor.execute_mod_query( reps: Mapped[int]
query, (approach.exercise_id, approach.weight, approach.reps) created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
) )
async def list(self) -> List[Dict[str, Any]]: exercise: Mapped["ExerciseModel"] = relationship(back_populates="approaches")
query = f"SELECT * FROM {self.TABLE_NAME}"
return await self._executor.execute_query(query)
async def delete(self, list_of_ids: List[int]) -> None:
placeholders: str = ",".join("?" * len(list_of_ids))
query = f"DELETE FROM {self.TABLE_NAME} WHERE id IN ({placeholders})"
await self._executor.execute_mod_query(query, tuple(list_of_ids))
return None

View file

@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View file

@ -1,26 +1,33 @@
from typing import List, Dict, Any from datetime import datetime
from app.core.database.connection import SQLiteExecutor from typing import TYPE_CHECKING, List
from app.core.dto.training import ExerciseDTO
from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database.models.base import Base
if TYPE_CHECKING:
from app.core.database.models.approach import ApproachModel
from app.core.database.models.training import TrainingModel
class ExerciseOps: class ExerciseModel(Base):
TABLE_NAME: str = "exercises" __tablename__ = "exercises"
def __init__(self, executor: SQLiteExecutor) -> None: id: Mapped[int] = mapped_column(primary_key=True)
self._executor = executor training_id: Mapped[int] = mapped_column(
ForeignKey("trainings.id", ondelete="CASCADE"), index=True
async def create(self, exercise: ExerciseDTO) -> int: )
query = f"INSERT INTO {self.TABLE_NAME} (training_id, name, splitted_weight) VALUES (?, ?, ?)" name: Mapped[str] = mapped_column(String(255), index=True)
return await self._executor.execute_mod_query( splitted_weight: Mapped[bool] = mapped_column(Boolean, server_default="false")
query, (exercise.training_id, exercise.name, exercise.splitted_weight) created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
) )
async def list(self) -> List[Dict[str, Any]]: training: Mapped["TrainingModel"] = relationship(back_populates="exercises")
query = f"SELECT * FROM {self.TABLE_NAME}" approaches: Mapped[List["ApproachModel"]] = relationship(
return await self._executor.execute_query(query) back_populates="exercise",
cascade="all, delete-orphan",
async def delete(self, list_of_ids: List[int]) -> None: passive_deletes=True,
placeholders: str = ",".join("?" * len(list_of_ids)) order_by="ApproachModel.id",
query = f"DELETE FROM {self.TABLE_NAME} WHERE id IN ({placeholders})" )
await self._executor.execute_mod_query(query, tuple(list_of_ids))
return None

View file

@ -1,26 +1,28 @@
from typing import List, Dict, Any from datetime import date as date_type, datetime
from app.core.database.connection import SQLiteExecutor from typing import TYPE_CHECKING, List, Optional
from app.core.dto.training import TrainingDTO
from sqlalchemy import Date, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database.models.base import Base
if TYPE_CHECKING:
from app.core.database.models.exercise import ExerciseModel
class TrainingOps: class TrainingModel(Base):
TABLE_NAME: str = "trainings" __tablename__ = "trainings"
def __init__(self, executor: SQLiteExecutor) -> None: id: Mapped[int] = mapped_column(primary_key=True)
self._executor = executor date: Mapped[date_type] = mapped_column(Date, index=True)
trainer: Mapped[Optional[str]] = mapped_column(String(100))
async def create(self, training: TrainingDTO) -> int: created_at: Mapped[datetime] = mapped_column(
query = f"INSERT INTO {self.TABLE_NAME} (date, trainer) VALUES (?, ?)" DateTime(timezone=True), server_default=func.now()
return await self._executor.execute_mod_query(
query, (training.date, training.trainer)
) )
async def list(self) -> List[Dict[str, Any]]: exercises: Mapped[List["ExerciseModel"]] = relationship(
query = f"SELECT * FROM {self.TABLE_NAME}" back_populates="training",
return await self._executor.execute_query(query) cascade="all, delete-orphan",
passive_deletes=True,
async def delete(self, list_of_ids: List[int]) -> None: order_by="ExerciseModel.id",
placeholders: str = ",".join("?" * len(list_of_ids)) )
query = f"DELETE FROM {self.TABLE_NAME} WHERE id IN ({placeholders})"
await self._executor.execute_mod_query(query, tuple(list_of_ids))
return None

View file

@ -0,0 +1,114 @@
from typing import List, Sequence
from sqlalchemy import delete, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import contains_eager, selectinload
from app.core.database.models import ApproachModel, ExerciseModel, TrainingModel
from app.core.dto.training import (
ApproachDTO,
ExerciseNameDTO,
ExerciseSessionDTO,
TrainingDTO,
)
class TrainingRepository:
"""Storage for trainings together with their exercises and approaches."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def save(self, training: TrainingDTO) -> int:
return (await self.save_many([training]))[0]
async def save_many(self, trainings: Sequence[TrainingDTO]) -> List[int]:
"""Save all trainings in a single transaction, returns their ids."""
models = [self._to_model(training) for training in trainings]
self._session.add_all(models)
await self._session.commit()
return [model.id for model in models]
async def list(self) -> List[TrainingDTO]:
query = (
select(TrainingModel)
.options(
selectinload(TrainingModel.exercises).selectinload(
ExerciseModel.approaches
)
)
.order_by(TrainingModel.date.desc(), TrainingModel.id.desc())
)
result = await self._session.scalars(query)
return [TrainingDTO.model_validate(model) for model in result]
async def list_exercise_names(self) -> List[ExerciseNameDTO]:
"""Distinct exercise names with the number of times each was done."""
query = select(ExerciseModel.name, func.count(ExerciseModel.id)).group_by(
ExerciseModel.name
)
result = await self._session.execute(query)
names = [ExerciseNameDTO(name=name, count=count) for name, count in result]
# Sorted here, not in SQL: the order must not depend on DB collation
return sorted(names, key=lambda item: item.name.casefold())
async def exercise_history(self, name: str) -> List[ExerciseSessionDTO]:
"""Every occurrence of the exercise, newest first."""
query = (
select(ExerciseModel)
.join(ExerciseModel.training)
.where(ExerciseModel.name == name)
.options(
contains_eager(ExerciseModel.training),
selectinload(ExerciseModel.approaches),
)
.order_by(TrainingModel.date.desc(), ExerciseModel.id.desc())
)
result = await self._session.scalars(query)
return [
ExerciseSessionDTO(
date=exercise.training.date,
trainer=exercise.training.trainer,
splitted_weight=exercise.splitted_weight,
approaches=[
ApproachDTO.model_validate(approach)
for approach in exercise.approaches
],
)
for exercise in result
]
async def count(self) -> int:
return await self._session.scalar(select(func.count(TrainingModel.id))) or 0
async def delete(self, list_of_ids: List[int]) -> int:
"""Delete trainings by ids (exercises and approaches go with them)."""
result = await self._session.execute(
delete(TrainingModel).where(TrainingModel.id.in_(list_of_ids))
)
await self._session.commit()
return result.rowcount # type: ignore[attr-defined]
async def delete_all(self) -> None:
await self._session.execute(
text("TRUNCATE trainings RESTART IDENTITY CASCADE")
)
await self._session.commit()
@staticmethod
def _to_model(training: TrainingDTO) -> TrainingModel:
return TrainingModel(
date=training.date,
trainer=training.trainer,
exercises=[
ExerciseModel(
name=exercise.name,
splitted_weight=exercise.splitted_weight,
approaches=[
ApproachModel(weight=approach.weight, reps=approach.reps)
for approach in exercise.approaches
],
)
for exercise in training.exercises
],
)

View file

@ -1,25 +1,46 @@
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict, Field
from datetime import date from datetime import date
class ApproachDTO(BaseModel): class DTOBase(BaseModel):
# Allows TrainingDTO.model_validate(<ORM model>)
model_config = ConfigDict(from_attributes=True)
class ApproachDTO(DTOBase):
id: Optional[int] = None id: Optional[int] = None
exercise_id: Optional[int] = None exercise_id: Optional[int] = None
weight: float weight: float
reps: int reps: int
class ExerciseDTO(BaseModel): class ExerciseDTO(DTOBase):
id: Optional[int] = None id: Optional[int] = None
training_id: Optional[int] = None training_id: Optional[int] = None
name: str name: str
splitted_weight: bool = False splitted_weight: bool = False
approaches: Optional[List[ApproachDTO]] = None approaches: List[ApproachDTO] = Field(default_factory=list)
class TrainingDTO(BaseModel): class TrainingDTO(DTOBase):
id: Optional[int] = None id: Optional[int] = None
date: date date: date
trainer: Optional[str] = None trainer: Optional[str] = None
exercises: Optional[List[ExerciseDTO]] = None exercises: List[ExerciseDTO] = Field(default_factory=list)
class ExerciseNameDTO(DTOBase):
"""Distinct exercise name and how many times it was done."""
name: str
count: int
class ExerciseSessionDTO(DTOBase):
"""One occurrence of an exercise in the history of that exercise."""
date: date
trainer: Optional[str] = None
splitted_weight: bool = False
approaches: List[ApproachDTO] = Field(default_factory=list)

120
app/core/metrics.py Normal file
View file

@ -0,0 +1,120 @@
"""Progress metrics of an exercise computed from its history.
Weighted exercises are tracked by the estimated one-rep max (e1RM) and by the
volume load; exercises done without extra weight (pull-ups, dips) by the best
set and the total number of reps.
"""
from dataclasses import dataclass
from datetime import date
from typing import List, Optional, Sequence
from app.core.dto.training import ApproachDTO, ExerciseSessionDTO
def estimate_1rm(weight: float, reps: int) -> float:
"""Epley formula: weight * (1 + reps / 30). A single rep is the weight itself."""
return weight if reps <= 1 else weight * (1 + reps / 30)
@dataclass(frozen=True)
class SessionMetrics:
max_weight: float
e1rm: Optional[float] # best set by e1RM, None when there are no weighted sets
volume: float # sum of weight * reps over weighted sets
total_reps: int # all sets, with or without weight
best_reps: int # reps of the best set
def session_metrics(approaches: Sequence[ApproachDTO]) -> SessionMetrics:
# Sets without weight (warm-ups, bodyweight) don't count for e1RM and volume
weighted_sets = [approach for approach in approaches if approach.weight > 0]
return SessionMetrics(
max_weight=max((approach.weight for approach in approaches), default=0.0),
e1rm=max(
(estimate_1rm(approach.weight, approach.reps) for approach in weighted_sets),
default=None,
),
volume=sum(approach.weight * approach.reps for approach in weighted_sets),
total_reps=sum(approach.reps for approach in approaches),
best_reps=max((approach.reps for approach in approaches), default=0),
)
@dataclass(frozen=True)
class SessionRow:
session: ExerciseSessionDTO
metrics: SessionMetrics
@dataclass(frozen=True)
class Record:
value: float
date: date
@dataclass(frozen=True)
class ExerciseProgress:
# True if any session has weighted sets: then e1RM and volume are the main
# metrics, otherwise reps of the best set and total reps
weighted: bool
rows: List[SessionRow] # same order as the history: newest first
max_weight: float
best_primary: Optional[Record] # e1RM / best set reps
best_secondary: Optional[Record] # volume / total reps of a session
first_primary: Optional[float] # in the oldest session that has the metric
last_primary: Optional[float] # in the newest session that has the metric
@property
def primary_change(self) -> Optional[float]:
"""Absolute change of the main metric between the first and the last session."""
if self.first_primary is None or self.last_primary is None:
return None
return self.last_primary - self.first_primary
@property
def primary_change_percent(self) -> Optional[float]:
change = self.primary_change
if change is None or not self.first_primary:
return None
return change / self.first_primary * 100
def summarize(history: Sequence[ExerciseSessionDTO]) -> ExerciseProgress:
"""Progress of an exercise; history is newest first (as the repository returns it)."""
rows = [SessionRow(item, session_metrics(item.approaches)) for item in history]
weighted = any(row.metrics.e1rm is not None for row in rows)
def primary(row: SessionRow) -> Optional[float]:
return row.metrics.e1rm if weighted else float(row.metrics.best_reps)
def secondary(row: SessionRow) -> float:
return row.metrics.volume if weighted else float(row.metrics.total_reps)
best_primary: Optional[Record] = None
best_secondary: Optional[Record] = None
first_primary: Optional[float] = None
last_primary: Optional[float] = None
# Oldest first: a record is kept on the date it was first reached
for row in reversed(rows):
value = primary(row)
if value is not None:
if first_primary is None:
first_primary = value
last_primary = value
if best_primary is None or value > best_primary.value:
best_primary = Record(value, row.session.date)
extra = secondary(row)
if best_secondary is None or extra > best_secondary.value:
best_secondary = Record(extra, row.session.date)
return ExerciseProgress(
weighted=weighted,
rows=rows,
max_weight=max((row.metrics.max_weight for row in rows), default=0.0),
best_primary=best_primary,
best_secondary=best_secondary,
first_primary=first_primary,
last_primary=last_primary,
)

View file

@ -1,15 +1,23 @@
import re import re
from typing import List, Tuple from typing import List, Optional
from datetime import datetime
from app.core.dto.training import TrainingDTO from app.core.dto.training import TrainingDTO
from app.core.parsers.apple_mapper import unique_apple_exercises_mapper from app.core.parsers.apple_mapper import unique_apple_exercises_mapper
from app.core.parsers.base import BaseNotesParser from app.core.parsers.base import BaseNotesParser, TrainingHeader
class AppleNotesParser(BaseNotesParser): class AppleNotesParser(BaseNotesParser):
"""Parser for Apple Notes format training data.""" """Parser for Apple Notes format training data."""
HEADER_PATTERN = re.compile(
r"^\*\*(?P<date>\d+.\d+.\d+)\s\((?P<trainer>.+?)(?:-(?P<year_counter>[^)]+))?\)\*\*"
)
REDUNDANT_LINES = [
"| | | |",
"|---|---|---|",
"|**Упражнение**|**Вес**|**Подходы**|",
]
def __init__(self): def __init__(self):
super().__init__("complete_data/apple.md", unique_apple_exercises_mapper) super().__init__("complete_data/apple.md", unique_apple_exercises_mapper)
@ -23,62 +31,22 @@ class AppleNotesParser(BaseNotesParser):
) )
cleaned_text = re.sub(r"^\n", "", cleaned_text, flags=re.MULTILINE) cleaned_text = re.sub(r"^\n", "", cleaned_text, flags=re.MULTILINE)
lines = cleaned_text.splitlines() lines = cleaned_text.splitlines()
redundant_lines = [ filtered_lines = [line for line in lines if line not in self.REDUNDANT_LINES]
"| | | |",
"|---|---|---|",
"|**Упражнение**|**Вес**|**Подходы**|",
]
filtered_lines = [line for line in lines if line not in redundant_lines]
return "\n".join(filtered_lines) return "\n".join(filtered_lines)
def parse_training_header(self, training_data_line: str) -> Tuple[bool, str, str, str]: def parse_training_header(self, training_data_line: str) -> Optional[TrainingHeader]:
"""Parse Apple Notes training header format.""" """Parse Apple Notes training header format."""
pattern: str = ( match = self.HEADER_PATTERN.search(training_data_line)
r"^\*\*(?P<date>\d+.\d+.\d+)\s\((?P<trainer>.+)(-(?P<year_counter>.+))?\)\*\*" if not match:
return None
year_counter = match.group("year_counter")
return TrainingHeader(
date=match.group("date").strip(),
trainer=match.group("trainer").strip(),
year_counter=year_counter.strip() if year_counter else None,
) )
match = re.search(pattern, training_data_line)
if match:
date = match.group("date").strip()
trainer = match.group("trainer").strip()
if match.group("year_counter"):
year_count = match.group("year_counter").strip()
else:
year_count = "0"
return True, date, trainer, year_count
return False, "", "", ""
def create_training_from_date(self, date_str: str) -> TrainingDTO:
"""Create Training object from date string with fallback parsing."""
try:
return TrainingDTO(
date=datetime.strptime(date_str, "%d.%m.%Y").date(), exercises=[]
)
except ValueError:
return TrainingDTO(
date=datetime.strptime(date_str, "%d.%m.%y").date(), exercises=[]
)
def parse(self, data: str) -> List[TrainingDTO]:
"""Parse Apple Notes training data from string input."""
# Override the data file reading with direct string input
original_method = self.read_data_file
self.read_data_file = lambda _: data
try:
trainings = self.parse_and_map_training_data()
return trainings
finally:
# Restore original method
self.read_data_file = original_method
def parse_training_data() -> List[TrainingDTO]: def parse_training_data() -> List[TrainingDTO]:
"""Parse Apple Notes training data.""" """Parse Apple Notes training data."""
parser = AppleNotesParser() return AppleNotesParser().parse_file()
return parser.parse_training_data()
def remap_unique_exercises(apple_trainings: List[TrainingDTO]) -> List[TrainingDTO]:
"""Remap exercise names using Apple-specific mapping (deprecated - use parser.parse_and_map_training_data())."""
parser = AppleNotesParser()
return parser.apply_exercise_mapping(apple_trainings)

View file

@ -179,6 +179,7 @@ unique_apple_exercises_mapper = {
"Жим от груди сидя в тренажере (около трицепс машины)": "Жим от груди сидя в тренажере (рычаги)", "Жим от груди сидя в тренажере (около трицепс машины)": "Жим от груди сидя в тренажере (рычаги)",
"Жим от груди сидя в тренажёре": "Жим от груди сидя в тренажере (рычаги)", "Жим от груди сидя в тренажёре": "Жим от груди сидя в тренажере (рычаги)",
"Жим от груди сидя в тренажёре (блинчики)": "Жим от груди сидя в тренажере (рычаги)", "Жим от груди сидя в тренажёре (блинчики)": "Жим от груди сидя в тренажере (рычаги)",
"Жим от груди сидя в тренажере (блинчики)": "Жим от груди сидя в тренажере (рычаги)",
"Силовой тренажер": "Жим от груди сидя в тренажере (рычаги)", "Силовой тренажер": "Жим от груди сидя в тренажере (рычаги)",
# Жим от плеч вверх в тренажере (рычаги) # Жим от плеч вверх в тренажере (рычаги)
@ -239,6 +240,7 @@ unique_apple_exercises_mapper = {
"Разгибание рук стоя с канатом": "Отжимание на трицепс в кроссовере", "Разгибание рук стоя с канатом": "Отжимание на трицепс в кроссовере",
"Трацепс": "Отжимание на трицепс в кроссовере", "Трацепс": "Отжимание на трицепс в кроссовере",
"Трицепс вниз у стойки": "Отжимание на трицепс в кроссовере", "Трицепс вниз у стойки": "Отжимание на трицепс в кроссовере",
"Отжимания на трицепс в кроссовере": "Отжимание на трицепс в кроссовере",
# Подтягивания # Подтягивания
"Подтягивание на заднюю дельту в петлях": "Подтягивания", "Подтягивание на заднюю дельту в петлях": "Подтягивания",
@ -253,6 +255,7 @@ unique_apple_exercises_mapper = {
# Подъем гантелей на бицепс стоя # Подъем гантелей на бицепс стоя
"Гантели": "Подъем гантелей на бицепс стоя", "Гантели": "Подъем гантелей на бицепс стоя",
"Жим гантелей стоя": "Подъем гантелей на бицепс стоя", "Жим гантелей стоя": "Подъем гантелей на бицепс стоя",
"Подъем гантелей стоя": "Подъем гантелей на бицепс стоя",
"Сгибание рук с гантелями стоя": "Подъем гантелей на бицепс стоя", "Сгибание рук с гантелями стоя": "Подъем гантелей на бицепс стоя",
"Сгибание рук с гантелями стоя ям": "Подъем гантелей на бицепс стоя", "Сгибание рук с гантелями стоя ям": "Подъем гантелей на бицепс стоя",

View file

@ -1,9 +1,22 @@
import logging
import os import os
from typing import List, Tuple, Dict, Optional from typing import Dict, List, NamedTuple, Optional
from app.core.dto.training import ApproachDTO, ExerciseDTO, TrainingDTO from app.core.dto.training import ApproachDTO, ExerciseDTO, TrainingDTO
from app.core.utils.date_refactor import parse_training_date from app.core.utils.date_refactor import parse_training_date
TABLE_HEADER_CELLS = {"Упражнение", "Вес", "Подходы"}
class ParseError(ValueError):
"""Raised when a line looks like training data but can't be parsed."""
class TrainingHeader(NamedTuple):
date: str
trainer: str
year_counter: Optional[str] = None
class BaseNotesParser: class BaseNotesParser:
"""Base class for parsing training data from different note formats.""" """Base class for parsing training data from different note formats."""
@ -12,6 +25,8 @@ class BaseNotesParser:
self.data_file_name = data_file_name self.data_file_name = data_file_name
self.project_root = os.getcwd() self.project_root = os.getcwd()
self.exercise_mapper = exercise_mapper or {} self.exercise_mapper = exercise_mapper or {}
# Lines skipped by the last parse() call
self.errors: List[str] = []
def get_data_path(self) -> str: def get_data_path(self) -> str:
return os.path.join(self.project_root, "data") return os.path.join(self.project_root, "data")
@ -21,120 +36,104 @@ class BaseNotesParser:
def read_data_file(self, file_name: str) -> str: def read_data_file(self, file_name: str) -> str:
path_to_file = self.get_data_file_path(file_name) path_to_file = self.get_data_file_path(file_name)
with open(path_to_file, "r") as f: with open(path_to_file, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
return content return content
def serialize_exercise(self, reps: str, weight: str, name: str) -> ExerciseDTO: def serialize_exercise(self, reps: str, weight: str, name: str) -> ExerciseDTO:
"""Convert raw exercise data into Exercise object with approaches.""" """Convert raw exercise data into Exercise object with approaches."""
try:
reps_list: List[int] = [int(rep) for rep in reps.split("-")] reps_list: List[int] = [int(rep) for rep in reps.split("-")]
weight_splitted: bool = False # "15х2" (cyrillic х) and "15x2" mean the same: 15 kg per hand
weight_list: List[float] = [] weight_tokens: List[str] = weight.replace("х", "x").split("-") if weight else []
# "0-12х2-16х2": the first approach may have no weight, so check all of them
weight_splitted: bool = any("x" in token for token in weight_tokens)
weight_list: List[float] = [float(w.split("x")[0]) for w in weight_tokens]
except ValueError as e:
raise ParseError(f"name={name!r}, weight={weight!r}, reps={reps!r}") from e
if weight: # Last weight is reused for the remaining approaches
weight_str_list: List[str] = [weight for weight in weight.split("-")] approaches = [
if any(split_anchor in weight_str_list[0] for split_anchor in ["x", "х"]): ApproachDTO(
weight_splitted = True weight=weight_list[min(index, len(weight_list) - 1)] if weight_list else 0.0,
splitter = "x" if "x" in weight_str_list[0] else "х" reps=rep,
weight_list = [
float(xweight.split(splitter)[0]) for xweight in weight_str_list
]
else:
weight_list = [float(w) for w in weight_str_list]
approaches = []
if not weight:
for rep_index in range(0, len(reps_list)):
approach = ApproachDTO(weight=0.0, reps=reps_list[rep_index])
approaches.append(approach)
else:
weight_pointer = 0
for rep_index in range(0, len(reps_list)):
approach = ApproachDTO(
weight=weight_list[weight_pointer], reps=reps_list[rep_index]
) )
if rep_index < len(weight_list) - 1: for index, rep in enumerate(reps_list)
weight_pointer += 1 ]
approaches.append(approach) return ExerciseDTO(
exercise = ExerciseDTO(
name=name, approaches=approaches, splitted_weight=weight_splitted name=name, approaches=approaches, splitted_weight=weight_splitted
) )
return exercise
def parse_training_exercises(self, exercise_line: str) -> ExerciseDTO: def parse_exercise_row(self, exercise_line: str) -> Optional[ExerciseDTO]:
"""Parse exercise data from a table row.""" """Parse exercise data from a table row.
stripped: List[str] = [entry.strip() for entry in exercise_line.split("|")][
1:-1 Returns None for lines that are not exercise rows (headings, separators, text)
] and raises ParseError for rows that are exercises but are malformed.
for entry in stripped: """
if entry in ["Упражнение", "Вес", "Подходы"]: if "|" not in exercise_line:
raise ValueError return None
if stripped: cells: List[str] = [entry.strip() for entry in exercise_line.split("|")][1:-1]
if "---" in stripped[0]: if not cells or "---" in cells[0] or TABLE_HEADER_CELLS & set(cells):
raise ValueError return None
if len(stripped) != 3: if len(cells) != 3:
raise ValueError raise ParseError(f"expected 3 columns, got {len(cells)}")
return self.serialize_exercise( name, weight, reps = cells
name=stripped[0], weight=stripped[1], reps=stripped[2] return self.serialize_exercise(name=name, weight=weight, reps=reps)
)
raise ValueError("No valid exercise data found")
def filter_training_data(self, training_data: str) -> str: def filter_training_data(self, training_data: str) -> str:
"""Filter and clean training data. Override in subclasses for specific formats.""" """Filter and clean training data. Override in subclasses for specific formats."""
return training_data return training_data
def parse_training_header( def parse_training_header(self, training_data_line: str) -> Optional[TrainingHeader]:
self, training_data_line: str
) -> Tuple[bool, str, str, str]:
"""Parse training header. Override in subclasses for specific formats.""" """Parse training header. Override in subclasses for specific formats."""
raise NotImplementedError("Subclasses must implement parse_training_header") raise NotImplementedError("Subclasses must implement parse_training_header")
def create_training_from_date(self, date_str: str) -> TrainingDTO: def parse(self, data: str, map_names: bool = True) -> List[TrainingDTO]:
"""Create Training object from date string using utility function.""" """Parse trainings from a string with notes."""
return TrainingDTO(date=parse_training_date(date_str), exercises=[]) self.errors = []
lines = self.filter_training_data(data).splitlines()
trainings: List[TrainingDTO] = []
current_training: Optional[TrainingDTO] = None
def parse_training_data(self) -> List[TrainingDTO]: for line_number, line in enumerate(lines, start=1):
"""Main parsing method. Override for specific parsing logic.""" header = self.parse_training_header(line)
training_data = self.filter_training_data( if header:
self.read_data_file(self.data_file_name) try:
current_training = TrainingDTO(
date=parse_training_date(header.date), trainer=header.trainer
) )
lines = training_data.splitlines() except ValueError as e:
# Exercises below belong to an unknown training, skip them
current_training = None current_training = None
trains = [] self._report_error(line_number, line, e)
continue
trainings.append(current_training)
continue
for index, line in enumerate(lines): if current_training is None:
header_parsed, date, _, _ = self.parse_training_header(line)
if index == len(lines) - 1:
trains.append(current_training)
if header_parsed:
trains.append(current_training)
current_training = self.create_training_from_date(date)
continue continue
try: try:
exr = self.parse_training_exercises(line) exercise = self.parse_exercise_row(line)
if current_training: except ParseError as e:
current_training.exercises.append(exr) self._report_error(line_number, line, e)
except ValueError: continue
pass if exercise:
current_training.exercises.append(exercise)
return [train for train in trains if train is not None] return self.apply_exercise_mapping(trainings) if map_names else trainings
def parse_file(self, map_names: bool = True) -> List[TrainingDTO]:
"""Parse trainings from the parser's data file."""
return self.parse(self.read_data_file(self.data_file_name), map_names=map_names)
def _report_error(self, line_number: int, line: str, error: Exception) -> None:
message = f"line {line_number}: {error} ({line.strip()!r})"
self.errors.append(message)
logging.warning(f"{type(self).__name__} skipped {message}")
def apply_exercise_mapping(self, trainings: List[TrainingDTO]) -> List[TrainingDTO]: def apply_exercise_mapping(self, trainings: List[TrainingDTO]) -> List[TrainingDTO]:
"""Apply exercise name mapping to all trainings.""" """Apply exercise name mapping to all trainings."""
for training in trainings: for training in trainings:
if not training or not training.exercises: for exercise in training.exercises or []:
continue exercise.name = self.exercise_mapper.get(exercise.name, exercise.name)
for exercise in training.exercises:
if not exercise:
continue
mapped_name = self.exercise_mapper.get(exercise.name)
if mapped_name is not None:
exercise.name = mapped_name
return trainings return trainings
def parse_and_map_training_data(self) -> List[TrainingDTO]:
"""Parse training data and apply exercise mapping."""
trainings = self.parse_training_data()
return self.apply_exercise_mapping(trainings)

View file

@ -1,14 +1,18 @@
import re import re
from typing import List, Tuple from typing import List, Optional
from app.core.dto.training import TrainingDTO from app.core.dto.training import TrainingDTO
from app.core.parsers.obsidian_mapper import obsidian_unique_exercies_mapping from app.core.parsers.obsidian_mapper import obsidian_unique_exercies_mapping
from app.core.parsers.base import BaseNotesParser from app.core.parsers.base import BaseNotesParser, TrainingHeader
class ObsidianNotesParser(BaseNotesParser): class ObsidianNotesParser(BaseNotesParser):
"""Parser for Obsidian Notes format training data.""" """Parser for Obsidian Notes format training data."""
HEADER_PATTERN = re.compile(
r"#\s(?P<date>\d+.\d+.\d+)\s\((?P<trainer>.+)-(?P<year_counter>.+)\)"
)
def __init__(self): def __init__(self):
super().__init__( super().__init__(
data_file_name="complete_data/obsidian.md", data_file_name="complete_data/obsidian.md",
@ -20,43 +24,18 @@ class ObsidianNotesParser(BaseNotesParser):
cleaned_text = re.sub(r"^\s*?\n", "", training_data, flags=re.MULTILINE) cleaned_text = re.sub(r"^\s*?\n", "", training_data, flags=re.MULTILINE)
return cleaned_text return cleaned_text
def parse_training_header( def parse_training_header(self, training_data_line: str) -> Optional[TrainingHeader]:
self, training_data_line: str
) -> Tuple[bool, str, str, str]:
"""Parse Obsidian Notes training header format.""" """Parse Obsidian Notes training header format."""
pattern: str = ( match = self.HEADER_PATTERN.search(training_data_line)
r"#\s(?P<date>\d+.\d+.\d+)\s\((?P<trainer>.+)-(?P<year_counter>.+)\)" if not match:
return None
return TrainingHeader(
date=match.group("date").strip(),
trainer=match.group("trainer").strip(),
year_counter=match.group("year_counter").strip(),
) )
match = re.search(pattern, training_data_line)
if match:
date = match.group("date").strip()
trainer = match.group("trainer").strip()
year_count = match.group("year_counter").strip()
return True, date, trainer, year_count
return False, "", "", ""
def parse(self, data: str) -> List[TrainingDTO]:
"""Parse Obsidian training data from string input."""
# Override the data file reading with direct string input
original_method = self.read_data_file
self.read_data_file = lambda _: data
try:
trainings = self.parse_and_map_training_data()
return trainings
finally:
# Restore original method
self.read_data_file = original_method
def parse_training_data() -> List[TrainingDTO]: def parse_training_data() -> List[TrainingDTO]:
"""Parse Obsidian Notes training data.""" """Parse Obsidian Notes training data."""
parser = ObsidianNotesParser() return ObsidianNotesParser().parse_file()
return parser.parse_training_data()
def remap_unique_exercises(obsidian_trainings: List[TrainingDTO]) -> List[TrainingDTO]:
"""Remap exercise names using Obsidian-specific mapping (deprecated - use parser.parse_and_map_training_data())."""
parser = ObsidianNotesParser()
return parser.apply_exercise_mapping(obsidian_trainings)

View file

@ -12,8 +12,8 @@ def parse_old_data() -> List[TrainingDTO]:
obsidian_parser = ObsidianNotesParser() obsidian_parser = ObsidianNotesParser()
apple_parser = AppleNotesParser() apple_parser = AppleNotesParser()
o_trainings = obsidian_parser.parse_and_map_training_data() o_trainings = obsidian_parser.parse_file()
a_trainings = apple_parser.parse_and_map_training_data() a_trainings = apple_parser.parse_file()
return o_trainings + a_trainings return o_trainings + a_trainings

View file

@ -19,7 +19,6 @@ class BaseTemplateResponse:
self, request: Request, template_name: str, context: Optional[dict] = None self, request: Request, template_name: str, context: Optional[dict] = None
): ):
base_context = { base_context = {
"request": request,
"title": "Fitness stats", "title": "Fitness stats",
"app_name": "Fitness parser" "app_name": "Fitness parser"
} }
@ -27,4 +26,4 @@ class BaseTemplateResponse:
if context: if context:
base_context.update(context) base_context.update(context)
return self.templates.TemplateResponse(template_name, base_context) return self.templates.TemplateResponse(request, template_name, base_context)

View file

@ -1,21 +1,23 @@
import re import re
from datetime import date from datetime import date
DATE_PATTERNS = [
def parse_training_date(raw_date: str) -> date: r"^(?P<day>[0-9]{2})\.(?P<month>[0-9]{2})\.(?P<year>[0-9]{4})",
patterns = [ r"^(?P<day>[0-9]{2})\.(?P<month>[0-9]{2})\.(?P<year>[0-9]{2})(?![0-9])",
r"^(?P<day>[0-9]{2})\.(?P<month>[0-9]{2})\.(?P<year>[0-9]{4})*", r"^(?P<year>[0-9]{4})\-(?P<month>[0-9]{2})\-(?P<day>[0-9]{2})",
r"^(?P<year>[0-9]{4})\-(?P<month>[0-9]{2})\-(?P<day>[0-9]{2})*", r"^(?P<year>[0-9]{4})\.(?P<month>[0-9]{2})\.(?P<day>[0-9]{2})",
r"^(?P<year>[0-9]{4})\.(?P<month>[0-9]{2})\.(?P<day>[0-9]{2})*",
r"^(?P<day>[0-9]{2})\.(?P<month>[0-9]{2})\.(?P<year>[0-9]{4})*",
] ]
for pattern in patterns:
def parse_training_date(raw_date: str) -> date:
for pattern in DATE_PATTERNS:
match = re.match(pattern, raw_date) match = re.match(pattern, raw_date)
if match: if match:
day = int(match.group("day")) day = int(match.group("day"))
month = int(match.group("month")) month = int(match.group("month"))
year = int(match.group("year")) year = int(match.group("year"))
if year < 100:
year += 2000
return date(year, month, day) return date(year, month, day)
raise ValueError(f"Не удалось найти дату в строке: {raw_date}") raise ValueError(f"Не удалось найти дату в строке: {raw_date}")

View file

@ -8,7 +8,7 @@ from fastapi.templating import Jinja2Templates
from app.api.v1.api import api_router from app.api.v1.api import api_router
from app.api.v1.web import web_router from app.api.v1.web import web_router
from app.config import settings from app.config import settings
from migrations.runner import MigrationRunner from app.core.database.connection import create_engine, create_session_factory
# TODO: Replace level with settings value # TODO: Replace level with settings value
logging.basicConfig( logging.basicConfig(
@ -18,10 +18,11 @@ logging.basicConfig(
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
logging.info(f"\n\n{settings.get_postgres_database_url()}") # Schema is managed by Alembic: run `alembic upgrade head` before starting the app
runner = MigrationRunner(settings.get_postgres_database_url()) engine = create_engine(settings.get_postgres_database_url())
await runner.run_migrations() app.state.session_factory = create_session_factory(engine)
yield yield
await engine.dispose()
# TODO: (#ToLearn) Почитать про lifespan в FastAPI приложениях # TODO: (#ToLearn) Почитать про lifespan в FastAPI приложениях

View file

@ -5,10 +5,10 @@ from pprint import pprint
om = ObsidianNotesParser() om = ObsidianNotesParser()
o_mapped_trainings = om.parse_training_data() o_mapped_trainings = om.parse_file()
am = AppleNotesParser() am = AppleNotesParser()
a_mapped_trainings = am.parse_training_data() a_mapped_trainings = am.parse_file()
combined_list = o_mapped_trainings + a_mapped_trainings combined_list = o_mapped_trainings + a_mapped_trainings
print(len(combined_list)) print(len(combined_list))

File diff suppressed because one or more lines are too long

View file

@ -6,8 +6,24 @@ body {
color: #333; color: #333;
} }
.navbar-brand { /* Sidebar navigation */
font-size: 1.5rem; .sidebar .nav-link:hover:not(.active) {
background: rgba(255, 255, 255, 0.1);
}
@media (min-width: 768px) {
.sidebar {
flex: 0 0 15rem;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
}
/* Lets wide tables scroll inside the content instead of stretching the page */
.content {
min-width: 0;
} }
.card { .card {
@ -23,6 +39,34 @@ body {
font-weight: 500; font-weight: 500;
} }
/* Text with a tooltip */
.has-tip {
cursor: help;
border-bottom: 1px dotted currentColor;
}
/* Trainings table */
td.date {
font-weight: 600;
}
/* DataTables right-aligns columns it detects as dates; dates read better on the left.
Same selector as in dataTables.bootstrap5.min.css, this file is loaded after it. */
table.dataTable th.dt-type-date,
table.dataTable td.dt-type-date {
text-align: left;
}
.approach {
display: inline-block;
margin: 0 0.35rem 0.15rem 0;
padding: 0.05rem 0.5rem;
border-radius: 0.75rem;
background: #eef2f7;
font-size: 0.875rem;
white-space: nowrap;
}
/* Footer */ /* Footer */
footer { footer {
margin-top: auto; margin-top: auto;

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,4 @@
/*! DataTables Bootstrap 5 integration
* © SpryMedia Ltd - datatables.net/license
*/
!function(n){var o,r;"function"==typeof define&&define.amd?define(["jquery","datatables.net"],function(t){return n(t,window,document)}):"object"==typeof exports?(o=require("jquery"),r=function(t,e){e.fn.dataTable||require("datatables.net")(t,e)},"undefined"==typeof window?module.exports=function(t,e){return t=t||window,e=e||o(t),r(t,e),n(e,0,t.document)}:(r(window,o),module.exports=n(o,window,window.document))):n(jQuery,window,document)}(function(d,t,e){"use strict";var n=d.fn.dataTable;return d.extend(!0,n.defaults,{renderer:"bootstrap"}),d.extend(!0,n.ext.classes,{container:"dt-container dt-bootstrap5",search:{input:"form-control form-control-sm"},length:{select:"form-select form-select-sm"},processing:{container:"dt-processing card"},layout:{row:"row mt-2 justify-content-between",cell:"d-md-flex justify-content-between align-items-center",tableCell:"col-12",start:"dt-layout-start col-md-auto me-auto",end:"dt-layout-end col-md-auto ms-auto",full:"dt-layout-full col-md"}}),n.ext.renderer.pagingButton.bootstrap=function(t,e,n,o,r){var a=["dt-paging-button","page-item"],o=(o&&a.push("active"),r&&a.push("disabled"),d("<li>").addClass(a.join(" ")));return{display:o,clicker:d("<button>",{class:"page-link",role:"link",type:"button"}).html(n).appendTo(o)}},n.ext.renderer.pagingContainer.bootstrap=function(t,e){return d("<ul/>").addClass("pagination").append(e)},n});

4
app/static/js/dataTables.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,247 @@
{
"processing": "Подождите...",
"search": "Поиск:",
"lengthMenu": "Показать _MENU_ записей",
"info": "Записи с _START_ до _END_ из _TOTAL_ записей",
"infoEmpty": "Записи с 0 до 0 из 0 записей",
"infoFiltered": "(отфильтровано из _MAX_ записей)",
"loadingRecords": "Загрузка записей...",
"zeroRecords": "Записи отсутствуют.",
"emptyTable": "В таблице отсутствуют данные",
"paginate": {
"first": "Первая",
"previous": "Предыдущая",
"next": "Следующая",
"last": "Последняя"
},
"aria": {
"sortAscending": ": активировать для сортировки столбца по возрастанию",
"sortDescending": ": активировать для сортировки столбца по убыванию"
},
"select": {
"rows": {
"_": "Выбрано записей: %d",
"1": "Выбрана одна запись"
},
"cells": {
"_": "Выбрано %d ячеек",
"1": "Выбрана 1 ячейка "
},
"columns": {
"1": "Выбран 1 столбец ",
"_": "Выбрано %d столбцов "
}
},
"searchBuilder": {
"conditions": {
"string": {
"startsWith": "Начинается с",
"contains": "Содержит",
"empty": "Пусто",
"endsWith": "Заканчивается на",
"equals": "Равно",
"not": "Не",
"notEmpty": "Не пусто",
"notContains": "Не содержит",
"notStartsWith": "Не начинается на",
"notEndsWith": "Не заканчивается на"
},
"date": {
"after": "После",
"before": "До",
"between": "Между",
"empty": "Пусто",
"equals": "Равно",
"not": "Не",
"notBetween": "Не между",
"notEmpty": "Не пусто"
},
"number": {
"empty": "Пусто",
"equals": "Равно",
"gt": "Больше чем",
"gte": "Больше, чем равно",
"lt": "Меньше чем",
"lte": "Меньше, чем равно",
"not": "Не",
"notEmpty": "Не пусто",
"between": "Между",
"notBetween": "Не между ними"
},
"array": {
"equals": "Равно",
"empty": "Пусто",
"contains": "Содержит",
"not": "Не равно",
"notEmpty": "Не пусто",
"without": "Без"
}
},
"data": "Данные",
"deleteTitle": "Удалить условие фильтрации",
"logicAnd": "И",
"logicOr": "Или",
"title": {
"0": "Конструктор поиска",
"_": "Конструктор поиска (%d)"
},
"value": "Значение",
"add": "Добавить условие",
"button": {
"0": "Конструктор поиска",
"_": "Конструктор поиска (%d)"
},
"clearAll": "Очистить всё",
"condition": "Условие",
"leftTitle": "Превосходные критерии",
"rightTitle": "Критерии отступа"
},
"searchPanes": {
"clearMessage": "Очистить всё",
"collapse": {
"0": "Панели поиска",
"_": "Панели поиска (%d)"
},
"count": "{total}",
"countFiltered": "{shown} ({total})",
"emptyPanes": "Нет панелей поиска",
"loadMessage": "Загрузка панелей поиска",
"title": "Фильтры активны - %d",
"showMessage": "Показать все",
"collapseMessage": "Скрыть все"
},
"buttons": {
"pdf": "PDF",
"print": "Печать",
"collection": "Коллекция <span class=\"ui-button-icon-primary ui-icon ui-icon-triangle-1-s\"><\/span>",
"colvis": "Видимость столбцов",
"colvisRestore": "Восстановить видимость",
"copy": "Копировать",
"copyTitle": "Скопировать в буфер обмена",
"csv": "CSV",
"excel": "Excel",
"pageLength": {
"-1": "Показать все строки",
"_": "Показать %d строк",
"1": "Показать 1 строку"
},
"removeState": "Удалить",
"renameState": "Переименовать",
"copySuccess": {
"1": "Строка скопирована в буфер обмена",
"_": "Скопировано %d строк в буфер обмена"
},
"createState": "Создать состояние",
"removeAllStates": "Удалить все состояния",
"savedStates": "Сохраненные состояния",
"stateRestore": "Состояние %d",
"updateState": "Обновить",
"copyKeys": "Нажмите ctrl или u2318 + C, чтобы скопировать данные таблицы в буфер обмена. Для отмены, щелкните по сообщению или нажмите escape."
},
"decimal": ".",
"infoThousands": ",",
"autoFill": {
"cancel": "Отменить",
"fill": "Заполнить все ячейки <i>%d<i><\/i><\/i>",
"fillHorizontal": "Заполнить ячейки по горизонтали",
"fillVertical": "Заполнить ячейки по вертикали",
"info": "Информация"
},
"datetime": {
"previous": "Предыдущий",
"next": "Следующий",
"hours": "Часы",
"minutes": "Минуты",
"seconds": "Секунды",
"unknown": "Неизвестный",
"amPm": [
"AM",
"PM"
],
"months": {
"0": "Январь",
"1": "Февраль",
"10": "Ноябрь",
"11": "Декабрь",
"2": "Март",
"3": "Апрель",
"4": "Май",
"5": "Июнь",
"6": "Июль",
"7": "Август",
"8": "Сентябрь",
"9": "Октябрь"
},
"weekdays": [
"Вс",
"Пн",
"Вт",
"Ср",
"Чт",
"Пт",
"Сб"
]
},
"editor": {
"close": "Закрыть",
"create": {
"button": "Новый",
"title": "Создать новую запись",
"submit": "Создать"
},
"edit": {
"button": "Изменить",
"title": "Изменить запись",
"submit": "Изменить"
},
"remove": {
"button": "Удалить",
"title": "Удалить",
"submit": "Удалить",
"confirm": {
"_": "Вы точно хотите удалить %d строк?",
"1": "Вы точно хотите удалить 1 строку?"
}
},
"multi": {
"restore": "Отменить изменения",
"title": "Несколько значений",
"info": "Выбранные элементы содержат разные значения для этого входа. Чтобы отредактировать и установить для всех элементов этого ввода одинаковое значение, нажмите или коснитесь здесь, в противном случае они сохранят свои индивидуальные значения.",
"noMulti": "Это поле должно редактироваться отдельно, а не как часть группы"
},
"error": {
"system": "Возникла системная ошибка (<a target=\"\\\" rel=\"nofollow\" href=\"\\\">Подробнее<\/a>)."
}
},
"searchPlaceholder": "Что ищете?",
"stateRestore": {
"creationModal": {
"button": "Создать",
"search": "Поиск",
"columns": {
"search": "Поиск по столбцам",
"visible": "Видимость столбцов"
},
"name": "Имя:",
"order": "Сортировка",
"paging": "Страницы",
"scroller": "Позиция прокрутки",
"searchBuilder": "Редактор поиска",
"select": "Выделение",
"title": "Создать новое состояние",
"toggleLabel": "Включает:"
},
"removeJoiner": "и",
"removeSubmit": "Удалить",
"renameButton": "Переименовать",
"duplicateError": "Состояние с таким именем уже существует.",
"emptyError": "Имя не может быть пустым.",
"emptyStates": "Нет сохраненных состояний",
"removeConfirm": "Вы уверены, что хотите удалить %s?",
"removeError": "Не удалось удалить состояние.",
"removeTitle": "Удалить состояние",
"renameLabel": "Новое имя для %s:",
"renameTitle": "Переименовать состояние"
},
"thousands": " "
}

View file

@ -10,4 +10,102 @@ $(document).ready(function() {
$('.card').on('click', function() { $('.card').on('click', function() {
$(this).toggleClass('bg-light'); $(this).toggleClass('bg-light');
}); });
// Bootstrap tooltips (explanations of the metrics)
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(
(element) => new bootstrap.Tooltip(element)
);
// Exercise search: open the history right after a suggestion is picked from the list.
// Typing, pasting and deleting have their own inputType, so a half-typed name that
// equals a shorter exercise name doesn't submit early; Enter/button always work.
const $exerciseInput = $('#exercise-input');
if ($exerciseInput.length) {
const exerciseNames = new Set(
$('#exercise-names option').map((_, option) => option.value).get()
);
$exerciseInput.on('input', function(event) {
const inputType = event.originalEvent && event.originalEvent.inputType;
const picked = !inputType || inputType === 'insertReplacementText';
if (picked && exerciseNames.has(this.value)) {
this.form.submit();
}
});
}
// Exercise progress chart (Chart.js): two series over time, each on its own y axis.
// Points are [UTC timestamp in ms, value]; the x axis is linear so gaps between
// trainings are proportional to the real time.
const chartDataElement = document.getElementById('progress-chart-data');
if (chartDataElement && window.Chart) {
const series = JSON.parse(chartDataElement.textContent).series;
const colors = ['#0d6efd', '#fd7e14'];
const formatDate = (timestamp, options) =>
new Date(timestamp).toLocaleDateString('ru-RU', { ...options, timeZone: 'UTC' });
const scales = {
x: {
type: 'linear',
ticks: {
maxTicksLimit: 8,
callback: (value) => formatDate(value, { month: 'short', year: 'numeric' }),
},
},
};
series.forEach((item, index) => {
scales['y' + index] = {
position: index === 0 ? 'left' : 'right',
grid: { drawOnChartArea: index === 0 },
title: { display: true, text: item.label, color: colors[index] },
};
});
new Chart(document.getElementById('progress-chart'), {
type: 'line',
data: {
datasets: series.map((item, index) => ({
label: item.label,
data: item.points.map(([x, y]) => ({ x, y })),
yAxisID: 'y' + index,
borderColor: colors[index],
backgroundColor: colors[index],
borderWidth: 2,
pointRadius: 3,
tension: 0.2,
})),
},
options: {
maintainAspectRatio: false,
interaction: { mode: 'nearest', axis: 'x', intersect: false },
scales,
plugins: {
tooltip: {
callbacks: {
title: (items) => formatDate(items[0].parsed.x, {}),
},
},
},
},
});
}
// Tables with search, sorting and paging by DataTables
const tableOptions = {
pageLength: 50,
lengthMenu: [25, 50, 100, 200],
order: [], // keep the order from the server: newest training first
language: { url: document.body.dataset.datatablesLang },
};
// All trainings: date, trainer, exercise, approaches
$('#trainings-table').DataTable({
...tableOptions,
columnDefs: [{ targets: 3, orderable: false }], // approaches
});
// History of one exercise: date, trainer, approaches, max weight
$('#exercise-history-table').DataTable({
...tableOptions,
columnDefs: [{ targets: 2, orderable: false }], // approaches
});
}); });

View file

@ -0,0 +1,54 @@
{# Approaches as "weight × reps" chips #}
{% macro approach_chips(approaches) %}{% for approach in approaches %}<span class="approach">{% if approach.weight %}{{ '%g'|format(approach.weight) }} кг{% else %}без веса{% endif %} × {{ approach.reps }}</span>{% endfor %}{% endmacro %}
{# One table for all trainings: a row per exercise. Search, sorting and paging come from DataTables (main.js) #}
{% macro trainings_table(trainings) %}
{% set ns = namespace(exercises=0) %}
{% for training in trainings %}{% set ns.exercises = ns.exercises + training.exercises|length %}{% endfor %}
<p class="text-muted">
Тренировок: <strong>{{ trainings|length }}</strong>,
упражнений: <strong>{{ ns.exercises }}</strong>
</p>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle" id="trainings-table">
<thead>
<tr>
<th scope="col">Дата</th>
<th scope="col">Тренер</th>
<th scope="col">Упражнение</th>
<th scope="col">Подходы (вес × повторения)</th>
</tr>
</thead>
<tbody>
{% for training in trainings %}
{% for exercise in training.exercises %}
<tr>
{# data-order: ISO date, so sorting by date is chronological #}
<td class="text-nowrap date" data-order="{{ training.date.isoformat() }}">{{ training.date.strftime('%d.%m.%Y') }}</td>
<td class="text-nowrap">{{ training.trainer or '' }}</td>
<td>
{{ exercise.name }}
{% if exercise.splitted_weight %}
<span class="badge text-bg-light border" title="Вес указан на каждую руку (гантель)">на руку</span>
{% endif %}
</td>
<td>
{{ approach_chips(exercise.approaches) }}
</td>
</tr>
{% else %}
{# DataTables needs the same number of cells in every row (no colspan) #}
<tr>
<td class="text-nowrap date" data-order="{{ training.date.isoformat() }}">{{ training.date.strftime('%d.%m.%Y') }}</td>
<td class="text-nowrap">{{ training.trainer or '' }}</td>
<td class="text-muted fst-italic">Упражнения не записаны</td>
<td></td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
{% endmacro %}

View file

@ -1,32 +1,42 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ title }}{% endblock %} - Fitness Parser</title> <title>{% block title %}{{ title }}{% endblock %} - Fitness Parser</title>
<link href="{{ url_for('static', path='/css/bootstrap.min.css') }}" rel="stylesheet"> <link href="{{ url_for('static', path='/css/bootstrap.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/main.css') }}" rel="stylesheet"> <link href="{{ url_for('static', path='/css/main.css') }}" rel="stylesheet">
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body> <body data-datatables-lang="{{ url_for('static', path='/js/i18n/datatables-ru.json') }}">
<!-- Navigation --> <div class="d-flex flex-column flex-md-row">
<nav class="navbar navbar-dark bg-primary"> <!-- Sidebar navigation: on narrow screens it turns into a strip on top -->
<div class="container"> <aside class="sidebar bg-dark p-3">
<!-- TODO: find solution to not harcode links in hrefs --> <a class="d-block text-white text-decoration-none fs-5 fw-bold mb-md-4 me-3" href="{{ url_for('home') }}">
<a class="navbar-brand" href="/app/obsidian/list"> {{ app_name }}
<strong>{{ app_name }}</strong>
</a> </a>
</div> <ul class="nav nav-pills flex-row flex-md-column gap-1 mt-2 mt-md-0">
</nav> {% for route, label in [('home', 'Тренировки'), ('obsidian_parse_form', 'Разобрать заметку'), ('exercises', 'Упражнения')] %}
<li class="nav-item">
<a class="nav-link text-white{% if request.url.path == url_for(route).path %} active{% endif %}" href="{{ url_for(route) }}">{{ label }}</a>
</li>
{% endfor %}
</ul>
</aside>
<!-- Main Content --> <!-- Main Content -->
<main class="container mt-4"> <main class="flex-grow-1 p-4 content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</div>
<script src="{{ url_for('static', path='/js/jquery-3.7.1.min.js') }}"></script> <script src="{{ url_for('static', path='/js/jquery-3.7.1.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/bootstrap.bundle.min.js') }}"></script> <script src="{{ url_for('static', path='/js/bootstrap.bundle.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.bootstrap5.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/main.js') }}"></script> <script src="{{ url_for('static', path='/js/main.js') }}"></script>
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}

View file

@ -0,0 +1,162 @@
{% extends "base.html" %}
{% from "_trainings_table.html" import approach_chips %}
{% block content %}
{% macro num(value) %}{{ '%g'|format(value|round(1)) }}{% endmacro %}
{# "+12 кг (+6.5%)" colored by direction; unit is "кг" or "повт." #}
{% macro change(value, percent, unit) %}
<span class="{% if value > 0 %}text-success{% elif value < 0 %}text-danger{% else %}text-muted{% endif %}">
{{ '%+g'|format(value|round(1)) }} {{ unit }}{% if percent is not none %} ({{ '%+.1f'|format(percent) }}%){% endif %}
</span> к первой тренировке
{% endmacro %}
{# Text with a dotted underline and a tooltip explaining it #}
{% macro tip(text, hint) %}<span class="has-tip" data-bs-toggle="tooltip" title="{{ hint }}">{{ text }}</span>{% endmacro %}
{% macro stat_card(title, value, note, hint) %}
<div class="col">
<div class="card h-100 mb-0">
<div class="card-body">
<div class="text-muted small">{{ tip(title, hint) }}</div>
<div class="fs-4 fw-semibold">{{ value }}</div>
<div class="text-muted small">{{ note }}</div>
</div>
</div>
</div>
{% endmacro %}
{% set hints = {
'record_e1rm': 'e1RM — расчётный максимум на один повтор по лучшему подходу тренировки (формула Эпли: вес × (1 + повторы / 30)). Рекорд — наибольшее значение за всю историю.',
'last_e1rm': 'e1RM лучшего подхода последней тренировки. Ниже — изменение относительно первой тренировки с этим упражнением.',
'record_volume': 'Тоннаж — суммарная работа за тренировку: сумма вес × повторы по всем подходам с весом. Рекорд — тренировка с наибольшим тоннажем.',
'max_weight': 'Самый тяжёлый вес в одном подходе за всю историю, без учёта повторов.',
'best_reps': 'Наибольшее число повторов в одном подходе за всю историю.',
'last_best_reps': 'Наибольшее число повторов в одном подходе последней тренировки. Ниже — изменение относительно первой тренировки.',
'record_total_reps': 'Наибольшая сумма повторов по всем подходам за одну тренировку.',
'e1rm': 'Расчётный максимум на один повтор по лучшему подходу тренировки (формула Эпли: вес × (1 + повторы / 30)). Подходы без веса не учитываются.',
'volume': 'Тоннаж: сумма вес × повторы по подходам с весом за тренировку.',
} %}
<h1 class="h3 mb-3">История упражнения</h1>
<form method="get" action="{{ url_for('exercises') }}" class="mb-4">
<label for="exercise-input" class="form-label">Упражнение</label>
<div class="input-group">
<input type="search" id="exercise-input" name="name" class="form-control" list="exercise-names"
value="{{ selected or query }}" placeholder="Начните вводить название…"{% if not query %} autofocus{% endif %}>
<button type="submit" class="btn btn-primary">Показать</button>
</div>
<datalist id="exercise-names">
{% for item in exercise_names %}<option value="{{ item.name }}"></option>{% endfor %}
</datalist>
</form>
{% if selected %}
{% set rows = progress.rows %}
<h2 class="h4">{{ selected }}</h2>
<p class="text-muted">
Тренировок: <strong>{{ rows|length }}</strong>,
первая: <strong>{{ rows[-1].session.date.strftime('%d.%m.%Y') }}</strong>,
последняя: <strong>{{ rows[0].session.date.strftime('%d.%m.%Y') }}</strong>
</p>
<div class="row row-cols-1 row-cols-sm-2 row-cols-xl-4 g-3 mb-4">
{% if progress.weighted %}
{{ stat_card('Рекорд e1RM', num(progress.best_primary.value) ~ ' кг', progress.best_primary.date.strftime('%d.%m.%Y'), hints.record_e1rm) }}
{{ stat_card('Последний e1RM', num(progress.last_primary) ~ ' кг',
change(progress.primary_change, progress.primary_change_percent, 'кг') if rows|length > 1 else '',
hints.last_e1rm) }}
{{ stat_card('Рекорд тоннажа', num(progress.best_secondary.value) ~ ' кг', progress.best_secondary.date.strftime('%d.%m.%Y'), hints.record_volume) }}
{{ stat_card('Максимальный вес', num(progress.max_weight) ~ ' кг', '', hints.max_weight) }}
{% else %}
{{ stat_card('Лучший подход', num(progress.best_primary.value) ~ ' повт.', progress.best_primary.date.strftime('%d.%m.%Y'), hints.best_reps) }}
{{ stat_card('Лучший подход в последний раз', num(progress.last_primary) ~ ' повт.',
change(progress.primary_change, progress.primary_change_percent, 'повт.') if rows|length > 1 else '',
hints.last_best_reps) }}
{{ stat_card('Рекорд суммы повторов', num(progress.best_secondary.value), progress.best_secondary.date.strftime('%d.%m.%Y'), hints.record_total_reps) }}
{% endif %}
</div>
<div class="card mb-2">
<div class="card-body">
<div style="height: 320px"><canvas id="progress-chart"></canvas></div>
</div>
</div>
<script type="application/json" id="progress-chart-data">{{ chart|tojson }}</script>
<p class="text-muted small mb-4">
{% if progress.weighted %}
e1RM — расчётный максимум на один повтор по формуле Эпли: вес × (1 + повторы / 30), берётся лучший подход
тренировки; подходы без веса не учитываются. Тоннаж — сумма вес × повторы по подходам с весом.
{% else %}
Упражнение выполняется без дополнительного веса, поэтому прогресс считается по повторам.
{% endif %}
</p>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle" id="exercise-history-table">
<thead>
<tr>
<th scope="col">Дата</th>
<th scope="col">Тренер</th>
<th scope="col">Подходы (вес × повторения)</th>
{% if progress.weighted %}
<th scope="col" class="text-end">Макс. вес</th>
<th scope="col" class="text-end">{{ tip('e1RM', hints.e1rm) }}</th>
<th scope="col" class="text-end">{{ tip('Тоннаж', hints.volume) }}</th>
{% else %}
<th scope="col" class="text-end">Лучший подход</th>
<th scope="col" class="text-end">Всего повторов</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for row in rows %}
{% set item = row.session %}
{% set m = row.metrics %}
<tr>
{# data-order: ISO date and plain numbers, so sorting is chronological/numeric #}
<td class="text-nowrap date" data-order="{{ item.date.isoformat() }}">{{ item.date.strftime('%d.%m.%Y') }}</td>
<td class="text-nowrap">{{ item.trainer or '' }}</td>
<td>
{{ approach_chips(item.approaches) }}
{% if item.splitted_weight %}
<span class="badge text-bg-light border" title="Вес указан на каждую руку (гантель)">на руку</span>
{% endif %}
</td>
{% if progress.weighted %}
<td class="text-nowrap text-end" data-order="{{ m.max_weight }}">{% if m.max_weight %}{{ num(m.max_weight) }} кг{% else %}—{% endif %}</td>
<td class="text-nowrap text-end" data-order="{{ m.e1rm or 0 }}">{% if m.e1rm %}{{ num(m.e1rm) }} кг{% else %}—{% endif %}</td>
<td class="text-nowrap text-end" data-order="{{ m.volume }}">{% if m.volume %}{{ num(m.volume) }} кг{% else %}—{% endif %}</td>
{% else %}
<td class="text-nowrap text-end" data-order="{{ m.best_reps }}">{{ m.best_reps }}</td>
<td class="text-nowrap text-end" data-order="{{ m.total_reps }}">{{ m.total_reps }}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif query %}
<div class="alert alert-warning">
Упражнение «{{ query }}» не найдено.
{% if suggestions %}Возможно, вы искали:{% endif %}
</div>
{% if suggestions %}
<div class="list-group">
{% for item in suggestions %}
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
href="{{ url_for('exercises').include_query_params(name=item.name) }}">
{{ item.name }}
<span class="badge text-bg-secondary rounded-pill">{{ item.count }}</span>
</a>
{% endfor %}
</div>
{% endif %}
{% elif not exercise_names %}
<div class="alert alert-info">
В базе пока нет тренировок. Загрузи их из заметок командой:
<br><code>docker compose exec fastapi-app python -m app.cli import</code>
</div>
{% endif %}
{% endblock %}
{% block extra_scripts %}
{% if chart %}<script src="{{ url_for('static', path='/js/chart.umd.js') }}"></script>{% endif %}
{% endblock %}

View file

@ -1,12 +1,15 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_trainings_table.html" import trainings_table %}
{% block content %} {% block content %}
<div class="mt-5"> <h1 class="h3 mb-3">Все тренировки</h1>
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">Fitness parser playground</h5>
</div>
</div>
</div>
{% if trainings %}
{{ trainings_table(trainings) }}
{% else %}
<div class="alert alert-info">
В базе пока нет тренировок. Загрузи их из заметок командой:
<br><code>docker compose exec fastapi-app python -m app.cli import</code>
</div>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -1,43 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_trainings_table.html" import trainings_table %}
{% block content %} {% block content %}
<h1>Training Sessions</h1> <h1 class="h3 mb-3">Результат разбора</h1>
{% if trainings %} {% if trainings %}
<p>Total trainings: {{ trainings|length }}</p> {{ trainings_table(trainings) }}
{% for training in trainings %}
<div class="training">
<h2>Date: {{ training.date }}</h2>
{% if training.exercises %}
<p>Exercises: {{ training.exercises|length }}</p>
{% for exercise in training.exercises %}
<div class="exercise">
<h3>{{ exercise.name }}</h3>
{% if exercise.splitted_weight %}
<span>(Split Weight)</span>
{% endif %}
<ul class="approaches">
{% for approach in exercise.approaches %}
<li>{{ approach.weight }}kg x {{ approach.reps }} reps</li>
{% endfor %}
</ul>
</div>
{% endfor %}
{% else %} {% else %}
<p>No exercises recorded</p> <p>Тренировки не найдены</p>
{% endif %} {% endif %}
</div>
<hr>
{% endfor %}
{% else %}
<p>No training data available</p>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -1,15 +1,15 @@
# Development overrides for hot reload # Development overrides for hot reload
version: '3.8'
services: services:
fastapi-app: fastapi-app:
environment: environment:
- DEBUG=true - DEBUG=true
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir /app/app --log-level debug command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir /app/app --log-level debug"
develop: develop:
watch: watch:
- action: sync - action: sync
path: ./app path: ./app
target: /app/app target: /app/app
- action: rebuild - action: rebuild
path: ./pyproject.toml path: ./requirements.txt
- action: rebuild
path: ./requirements-dev.txt

View file

@ -1,5 +1,3 @@
version: '3.8'
services: services:
fastapi-app: fastapi-app:
build: build:
@ -11,12 +9,15 @@ services:
- ./app:/app/app:ro - ./app:/app/app:ro
- ./data:/app/data:ro - ./data:/app/data:ro
- ./tests:/app/tests:ro - ./tests:/app/tests:ro
- ./migrations:/app/migrations:ro # Writable: `alembic revision --autogenerate` creates files in migrations/versions
- ./migrations:/app/migrations
env_file: env_file:
- .env - .env
environment: environment:
- PYTHONPATH=${PYTHONPATH} - PYTHONPATH=${PYTHONPATH:-/app}
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir /app/app # Tests wipe their tables, so they use a separate database (created on demand)
- TEST_DATABASE_URL=postgresql+asyncpg://${POSTGRES_DATABASE_USER}:${POSTGRES_DATABASE_PASSWORD}@postgres:5432/${POSTGRES_DATABASE_DB}_test
command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir /app/app"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

View file

@ -86,7 +86,7 @@
|---|---|---| |---|---|---|
|Подтягивания|Синяя резинка|7-5-4-4| |Подтягивания|Синяя резинка|7-5-4-4|
|Тяга горизонтального блока|45|12-12-12-12| |Тяга горизонтального блока|45|12-12-12-12|
|Тяга вертикального блока обратным хватом|49.8х2-45|10-10-9| |Тяга вертикального блока обратным хватом|49.8-45|10-10-9|
|Отжимания|+5|4-5-4| |Отжимания|+5|4-5-4|
|Подъем ног в висе||10-10-8| |Подъем ног в висе||10-10-8|
@ -304,7 +304,7 @@
|Жим от груди лежа (гантели)|16x2-18x2|12-8-10| |Жим от груди лежа (гантели)|16x2-18x2|12-8-10|
|Сведение рук пек дек|37|12-12-12-12| |Сведение рук пек дек|37|12-12-12-12|
|Подъем штанги стоя|20|12-11-9-9| |Подъем штанги стоя|20|12-11-9-9|
|Молоты стоя|20-20-18|10-9-12| |Молоты стоя|20х2-20х2-18х2|10-9-12|
**01.12.2023 (с56)**  **01.12.2023 (с56)** 
@ -402,7 +402,7 @@
|---|---|---| |---|---|---|
|Гравитрон|57-47|12-9-7-6| |Гравитрон|57-47|12-9-7-6|
|Гребная тяга с упором в грудь|37|12-12-12| |Гребная тяга с упором в грудь|37|12-12-12|
|Тяга гантели в наклоне|18|12-12-12-12| |Тяга гантели в наклоне|18х2|12-12-12-12|
|Подъем штанги стоя|20|13-12-12| |Подъем штанги стоя|20|13-12-12|
|Жим от плеч вверх (гантели)|20х2|7-7-12| |Жим от плеч вверх (гантели)|20х2|7-7-12|
@ -463,7 +463,7 @@
|Упражнение|Вес|Подходы| |Упражнение|Вес|Подходы|
|---|---|---| |---|---|---|
|Болгарские выпады|8x2-10x2|12-12-12| |Болгарские выпады|8x2-10x2|12-12-12|
|Румынская тяга|32|12-12-12| |Румынская тяга|16х2|12-12-12|
|Дельт-машина |47-57-47|12-10-12-12| |Дельт-машина |47-57-47|12-10-12-12|
|Дельт-машина|27|12-12-12-12| |Дельт-машина|27|12-12-12-12|
|Жим от плеч вверх (гантели)|14x2|12-12-11-10| |Жим от плеч вверх (гантели)|14x2|12-12-11-10|
@ -526,7 +526,7 @@
|---|---|---| |---|---|---|
|Подтягивания|0-0-47-47|4-3-9-8| |Подтягивания|0-0-47-47|4-3-9-8|
|Тяга штанги в наклоне|20-40|11-12-12-12| |Тяга штанги в наклоне|20-40|11-12-12-12|
|Тяга одной рукой в кроссовере на колене|41|12-12-12| |Тяга одной рукой в кроссовере на колене|20.5х2|12-12-12|
|Жим от груди сидя в тренажере (блинчики)|20x2|12-12-12| |Жим от груди сидя в тренажере (блинчики)|20x2|12-12-12|
|Сведение рук пек дек|47-37|6-11-10| |Сведение рук пек дек|47-37|6-11-10|
@ -673,7 +673,7 @@
|Упражнение|Вес|Подходы| |Упражнение|Вес|Подходы|
|---|---|---| |---|---|---|
|Подтягивания|0-0-0-47|5-4-3-11| |Подтягивания|0-0-0-47|5-4-3-11|
|Тяга гантелей к поясу лежа на животе на скамье 45°|28-36|12-12-12-12| |Тяга гантелей к поясу лежа на животе на скамье 45°|14х2-18х2|12-12-12-12|
|Гребная тяга с упором в грудь|37-47|12-11-10| |Гребная тяга с упором в грудь|37-47|12-11-10|
|Жим от груди сидя в тренажере (блинчики)|20x2-25x2|12-12-12| |Жим от груди сидя в тренажере (блинчики)|20x2-25x2|12-12-12|
|Сведение рук пек дек|47-37|11-9-12-11| |Сведение рук пек дек|47-37|11-9-12-11|
@ -683,7 +683,7 @@
|Упражнение|Вес|Подходы| |Упражнение|Вес|Подходы|
|---|---|---| |---|---|---|
|Разгибание ног сидя|47-67-77|12-12-12-12| |Разгибание ног сидя|47-67-77|12-12-12-12|
|Румынская тяга|44|12-12-12| |Румынская тяга|22х2|12-12-12|
|Жим от плеч вверх (гантели)|14x2-16x2|12-12-12-10| |Жим от плеч вверх (гантели)|14x2-16x2|12-12-12-10|
|Дельт-машина|47|12-12-12| |Дельт-машина|47|12-12-12|
|Разведение рук в наклоне|8х2|12-12-12| |Разведение рук в наклоне|8х2|12-12-12|
@ -866,7 +866,7 @@
|Упражнение|Вес|Подходы| |Упражнение|Вес|Подходы|
|---|---|---| |---|---|---|
|Разгибание ног сидя|58-77-86|12-12-12-10-10| |Разгибание ног сидя|58-77-86|12-12-12-10-10|
|Румынская тяга|40|12-12-12-12| |Румынская тяга|20х2|12-12-12-12|
|Махи гантелей в стороны стоя|8х8-10х10|12-12-12| |Махи гантелей в стороны стоя|8х8-10х10|12-12-12|
|Разведение рук пек дек|27|12-12-10| |Разведение рук пек дек|27|12-12-10|
|Жим от плеч вверх (гантели)|16х2|12-12| |Жим от плеч вверх (гантели)|16х2|12-12|
@ -878,7 +878,7 @@
|Подтягивания||8-6-4| |Подтягивания||8-6-4|
|Тяга горизонтального блока|58.6-58.6-54|12-12-12-12| |Тяга горизонтального блока|58.6-58.6-54|12-12-12-12|
|Тяга вертикального блока обратным хватом|49.8|12-12-12| |Тяга вертикального блока обратным хватом|49.8|12-12-12|
|Жим от груди сидя в тренажере (блинчики)|67-87|12-12-10-10| |Жим от груди сидя в тренажере (блинчики)|33.5х2-43.5х2|12-12-10-10|
|Брусья|47|9-9-9| |Брусья|47|9-9-9|
**10.07.2024 (с108-54)** **10.07.2024 (с108-54)**
@ -1019,7 +1019,7 @@
|---|---|---| |---|---|---|
|Гравитрон|47-37|12-9-8| |Гравитрон|47-37|12-9-8|
|Тяга горизонтального блока|48.9-54.4|12-12-12-12| |Тяга горизонтального блока|48.9-54.4|12-12-12-12|
|Тяга гантели в наклоне|22-22-24|12-12-12| |Тяга гантели в наклоне|22х2-22х2-24х2|12-12-12|
|Жим от груди сидя в тренажере (кирпичики)|77|12-12-10-8| |Жим от груди сидя в тренажере (кирпичики)|77|12-12-10-8|
|Баттерфляй|57-47|10-12-12| |Баттерфляй|57-47|10-12-12|
@ -1062,7 +1062,7 @@
|Гравитрон|47|12-12-9-10| |Гравитрон|47|12-12-9-10|
|Гребная тяга с упором в грудь|47|12-13-11| |Гребная тяга с упором в грудь|47|12-13-11|
|Тяга гантели в наклоне|20 на каждую руку|12-12-12| |Тяга гантели в наклоне|20 на каждую руку|12-12-12|
|Жим от груди сидя в тренажере (блинчики)|30-50|14-12-12-12| |Жим от груди сидя в тренажере (блинчики)|15х2-25х2|14-12-12-12|
|Сведение рук пек дек|37|12-12-12| |Сведение рук пек дек|37|12-12-12|
**08.05.2024 (solo-35)** **08.05.2024 (solo-35)**

View file

@ -1,3 +1,792 @@
# 2026-10-21 (solo-107)
| Упражнение | Вес | Подходы |
| ------------------------------------------- | -------- | ----------- |
| Подтягивания | 78.5 | 12-10-8-8 |
| Тяга Т-грифа | 40-45 | 12-8-8-8 |
| Жим от груди сидя в тренажере (блинчики) | 30х2 | 11-10-9-6 |
| Разгибание ног сидя | 100 | 12-12-10-12 |
| Разведение ног сидя в тренажёре (кирпичики) | 70-75-80 | 12-12-10 |
# 2026-10-19 (solo-106)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ------ | ----------- |
| Подъем штанги стоя | 35 | 10-9-9-8 |
| Отжимания на трицепс в кроссовере | 55-60 | 12-12-10-10 |
| Разведение рук с гантелей из-за головы | 27.5 | 10-9-8-9 |
| Жим от плеч вверх (гантели) | 22.5х2 | 8-8-9-8 |
| Отведение рук пек дек (1 рука) | 22х2 | 12-12-12-12 |
# 2026-10-17 (solo-105)
| Упражнение | Вес | Подходы |
| ------------------------------------- | ------ | --------- |
| Подтягивания | 78.9 | 11-10-7-7 |
| Тяга вертикального блока узким хватом | 55 | 8-9-8-9 |
| Жим от груди лежа (гантели) | 27.5х2 | 10-8-7-7 |
| Сведение рук пек дек | 60 | 10-9-9-9 |
| Поворот гири на лямке | 12.5х2 | 10-11-11 |
# 2026-10-15 (solo-104)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ----------- | ----------- |
| Подъем гантелей стоя | 20х2 | 12-11-10-10 |
| Молоты стоя | 20х2 | 11-11-10-10 |
| Разведение рук с гантелей из-за головы | 27.5 | 8-10-9-10 |
| Жим от плеч вверх (гантели) | 20х2-22.5х2 | 12-10-7-8 |
| Махи гантелей в стороны стоя | 15х2 | 10-10-9-10 |
# 2026-10-12 (solo-103)
| Упражнение | Вес | Подходы |
| ----------------------------------------- | ------ | --------- |
| Подтягивания | 79.5 | 11-10-7-7 |
| Тяга горизонтального блока | 55-65 | 12-8-8-9 |
| Жим от груди лежа (гантели) | 27.5х2 | 10-8-8-7 |
| Жим от груди сидя в тренажере (кирпичики) | 70-80 | 12-7-8-8 |
| Пресс на скамье | | 12-12-12 |
# 2026-10-10 (solo-102)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ------ | ----------- |
| Бицепс в тренажере | 25 | 10-8-8-7 |
| Подъем гантелей стоя | 17.5х2 | 12-12-10-10 |
| Французский жим лежа (штанга) | 30 | 10-10-7-8 |
| Жим от плеч вверх в тренажере (блинчики) | 17.5х2 | 8-7-8-7 |
| Поворот гири на лямке | 12х2 | 10-10-9-9 |
# 2026-10-07 (solo-101)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ------------------- | ----------- |
| Подтягивания | 79 | 10-10-8-8 |
| Гребная тяга с упором в грудь (1 рука) | 15х2-20х2-25х2-30х2 | 12-12-12-12 |
| Жим от груди лежа (гантели) | 27.5х2 | 10-8-7-6 |
| Разгибание ног сидя | 90-100 | 12-12-11-11 |
| Икроножные сидя | 40-50 | 12-12-12 |
# 2026-10-05 (solo-100)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ---------- | --------- |
| Бицепс в тренажере | 25 | 10-8-8-7 |
| Французский жим лежа (штанга) | 30 | 10-10-9-7 |
| Разведение рук с гантелей из-за головы | 25-25-27.5 | 11-12-8-7 |
| Жим от плеч вверх (гантели) | 22.5x2 | 8-8-8-7 |
| Поворот гири на лямке | 10х2-12х2 | 12-10-7-8 |
# 2026-10-03 (solo-99)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ----------- | ----------- |
| Подтягивания | 79.3 | 10-8-7-7 |
| Тяга гантели в наклоне | 27.5х2-30х2 | 12-9-10-10 |
| Жим от груди сидя в тренажере (блинчики) | 30х2 | 10-10-7-7 |
| Сведение рук пек дек | 55 | 10-10-10-10 |
| Поворот гири на лямке | 10х2 | 12-12-12-12 |
# 2026-10-01 (solo-98)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ----------- | ----------- |
| Подъем гантелей стоя | 17.5x2-20x2 | 12-10-10-10 |
| Французский жим лежа (штанга) | 30 | 9-10-8-7 |
| Разведение рук с гантелей из-за головы | 22.5 | 10-10-10-10 |
| Жим от плеч вверх (гантели) | 20х2-22.5х2 | 12-8-6-7 |
| Махи гантелей в стороны стоя | 12.5х2-15х2 | 12-11-8 |
# 2026-09-28 (solo-97)
| Упражнение | Вес | Подходы |
| ------------------------------------ | ----- | ----------- |
| Подтягивания | 79.3 | 9-8-8-6 |
| Тяга вертикального блока (кирпичики) | 80-90 | 12-9-10-9 |
| Жим от груди лежа (гантели) | 25х2 | 10-9-8-7 |
| Сведение рук пек дек | 55 | 11-9-8-9 |
| Пресс на скамье | | 12-12-12-12 |
# 2026-09-19 (solo-96)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ------ | ----------- |
| Бицепс в тренажере | 25 | 11-8-7-7 |
| Молоты стоя | 17.5х2 | 11-11-12-10 |
| Разведение рук с гантелей из-за головы | 25 | 8-9-10-11 |
| Жим от плеч вверх (гантели) | 20х2 | 12-10-9-10 |
| Махи гантелей в стороны стоя | 15х2 | 10-11-10-11 |
# 2026-09-14 (solo-95)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ------------------ | ---------- |
| Подтягивания | 80 | 10-10-8-7 |
| Тяга Т-грифа | 40-45 | 12-10-9-8 |
| Жим от груди сидя в тренажере (блинчики) | 30х2 | 12-10-8-8 |
| Жим от груди лежа (гантели) | 27.5х2-27.5х2-25х2 | 6-6-7-7 |
| Поворот гири на лямке | 10х2-12х2-10х2 | 12-7-12-12 |
# 2026-09-12 (solo-94)
| Упражнение | Вес | Подходы |
| --------------------------------- | ----- | ----------- |
| Бицепс в тренажере | 25 | 11-9-7-7 |
| Отведение рук пек дек | 35-40 | 12-12-10-11 |
| Французский жим лежа (штанга) | 30 | 8-8-8-8 |
| Отжимания на трицепс в кроссовере | 50 | 12-12-12-12 |
| Пресс на скамье | | 14-12-12-12 |
# 2026-09-09 (solo-93)
| Упражнение | Вес | Подходы |
| ------------------------------------ | --------- | ----------- |
| Подтягивания | 79.4 | 11-9-7-7 |
| Тяга вертикального блока (кирпичики) | 50-55 | 12-10-10-10 |
| Жим от груди лежа (гантели) | 27.5х2 | 10-8-8-7 |
| Отжимания на брусьях | 79.4 | 8-7-7-7 |
| Махи рукой в кроссовере | 20х2-21х2 | 12-10-11-11 |
# 2026-09-07 (solo-92)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ---- | ----------- |
| Подъем штанги стоя | 35 | 11-9-8-8 |
| Французский жим лежа (штанга) | 30 | 12-10-8-6 |
| Разведение рук с гантелей из-за головы | 25 | 10-10-8-8 |
| Жим от плеч вверх (гантели) | 20х2 | 11-11-8-11 |
| Пресс на скамье | | 12-12-12-12 |
# 2026-09-05 (solo-91)
| Упражнение | Вес | Подходы |
| ----------------------------------------- | ----- | ----------- |
| Подтягивания | 79.9 | 12-8-6-6 |
| Тяга вертикального блока узким хватом | 50 | 12-11-9-10 |
| Сведение рук пек дек | 55 | 12-12-12-12 |
| Жим от груди сидя в тренажере (кирпичики) | 60-65 | 12-10-11-12 |
| Жим от плеч вверх в тренажере (кирпичики) | 35-40 | 12-10-10-10 |
# 2026-08-20 (solo-90)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ------ | ----------- |
| Бицепс в тренажере | 25 | 12-10-10-9 |
| Молоты стоя | 17.5х2 | 12-12-12-12 |
| Французский жим лежа (штанга) | 30 | 10-10-8-5 |
| Разведение рук с гантелей из-за головы | 22.5 | 12-12-12-12 |
| Поворот гири на лямке | 10х2 | 12-12-12-12 |
# 2026-08-17 (solo-89)
| Упражнение | Вес | Подходы |
| ------------------------------------- | ------ | ---------- |
| Подтягивания | 78.3 | 12-10-8-7 |
| Тяга вертикального блока узким хватом | 50-55 | 12-10-9-9 |
| Жим от груди лежа (гантели) | 27.5x2 | 10-8-8-6 |
| Сведение рук пек дек | 60 | 10-10-10-9 |
| Махи рукой в кроссовере | 20х2 | 12-12-12 |
# 2026-08-15 (solo-88)
| Упражнение | Вес | Подходы |
| ------------------------------------ | ----------- | ----------- |
| Подъем штанги стоя | 35-35-30 | 9-7-12-9 |
| Подъем гантелей стоя обратным хватом | 15х2-17.5х2 | 12-12-12-12 |
| Французский жим лежа (штанга) | 30 | 10-10-9-7 |
| Жим от плеч вверх (гантели) | 22.5х2-20х2 | 8-10-10-10 |
| Махи гантелей в стороны стоя | 15х2 | 12-11-11-11 |
# 2026-08-13 (solo-87)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ------ | ----------- |
| Подтягивания | 77.6 | 12-8-8-7 |
| Тяга Т-грифа | 45 | 10-9-10-9 |
| Жим от груди лежа (гантели) | 27.5х2 | 10-8-7-8 |
| Жим от груди сидя в тренажере (блинчики) | 30х2 | 8-9-8-8 |
| Пресс на скамье | | 12-12-12-12 |
# 2026-08-10 (solo-86)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ------ | ----------- |
| Подъем гантелей стоя | 20х2 | 12-12-10-10 |
| Французский жим лежа (штанга) | 30 | 10-9-8-7 |
| Разведение рук с гантелей из-за головы | 25 | 12-12-12-12 |
| Жим от плеч вверх (гантели) | 22.5х2 | 10-9-7-7 |
| Поворот гири на лямке | 10х2 | 12-12-12-12 |
# 2026-08-08 (solo-85)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ----- | ----------- |
| Подтягивания | 78.1 | 12-10-7-7 |
| Тяга вертикального блока узким хватом | 50-55 | 12-10-11-10 |
| Жим от груди сидя в тренажере (блинчики) | 30х2 | 12-9-8-7 |
| Сведение рук пек дек | 60 | 10-11-10-9 |
| Пресс на скамье | | 12-12-12-12 |
# 2026-08-06 (solo-84)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ----------- | ----------- |
| Подъем гантелей стоя | 20х2 | 12-12-10-10 |
| Французский жим лежа (штанга) | 30 | 10-10-10-8 |
| Разведение рук с гантелей из-за головы | 25 | 9-10-10-12 |
| Жим от плеч вверх в тренажере (блинчики) | 15х2-17.5х2 | 12-10-7-9 |
| Махи рукой в кроссовере | 20х2 | 12-12-12-12 |
# 2026-08-03 (solo-83)
| Упражнение | Вес | Подходы |
| ----------------------------------------- | ----------- | ---------- |
| Подтягивания | 78.9 | 12-12-9-7 |
| Тяга Т-грифа | 45 | 10-9-10-10 |
| Жим от груди лежа (гантели) | 27.5х2 | 10-9-8-7 |
| Сведение рук пек дек | 65 | 9-9-8-8 |
| Жим от плеч вверх в тренажере (кирпичики) | 40-45-45-40 | 12-9-6 |
# 2026-08-01 (solo-82)
| Упражнение | Вес | Подходы |
| -------------------------------------- | -------- | ----------- |
| Подъем гантелей стоя | 20х2 | 12-10-10-10 |
| Бицепс в тренажере | 25-25-20 | 10-7-10-10 |
| Отжимания на трицепс в кроссовере | 65-60 | 10-12-11-10 |
| Разведение рук с гантелей из-за головы | 25 | 8-10-10-8 |
| Поворот гири на лямке | 10х2 | 12-12-12 |
# 2026-07-29 (solo-81)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ---- | ---------- |
| Подтягивания | 78.6 | 11-10-9-8 |
| Тяга вертикального блока узким хватом | 60 | 10-10-9-7 |
| Жим от груди сидя в тренажере (блинчики) | 30х2 | 12-9-8-7 |
| Сведение рук пек дек | 60 | 10-9-10-10 |
| Пресс на скамье | | 12-12-12 |
# 2026-07-24 (solo-80)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ----------- | ----------- |
| Бицепс в тренажере | 25 | 12-10-8-8 |
| Молоты стоя | 15х2-17.5х2 | 12-12-12-12 |
| Французский жим лежа (штанга) | 30 | 12-10-8-8 |
| Разведение рук с гантелей из-за головы | 25 | 8-10-10-10 |
| Поворот гири на лямке | 10х2 | 12-12-12 |
# 2026-07-22 (solo-79)
| Упражнение | Вес | Подходы |
| ----------------------------------------- | -------- | ----------- |
| Подтягивания | 78 | 12-10-9-8 |
| Тяга Т-грифа | 40 | 11-12-11-9 |
| Жим от груди сидя в тренажере (кирпичики) | 60-70-75 | 15-12-10-9 |
| Жим от груди лежа (гантели) | 25х2 | 9-8-9-8 |
| Пресс на скамье | | 12-12-12-12 |
# 2026-07-20 (solo-78)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ---- | ----------- |
| Подъем штанги стоя | 35 | 10-10-8-8 |
| Французский жим лежа (штанга) | 30 | 12-10-10-8 |
| Разведение рук с гантелей из-за головы | 25 | 10-12-11-10 |
| Жим от плеч вверх в тренажере (блинчики) | 15х2 | 12-12-9-10 |
| Поворот гири на лямке | 10х2 | 12-12-12-12 |
# 2026-07-18 (solo-77)
| Упражнение | Вес | Подходы |
| ------------------------------------------- | -------- | ----------- |
| Подтягивания | 78 | 11-9-8-7 |
| Тяга горизонтального блока | 50-60-65 | 12-12-9-9 |
| Разведение рук пек дек | 35 | 12-12-9-10 |
| Сгибание ног лежа | 45-50 | 12-12-10-8 |
| Разведение ног сидя в тренажёре (кирпичики) | 85 | 12-12-12-12 |
# 2026-07-16 (solo-76)
| Упражнение | Вес | Подходы |
| ----------------------------------------- | ----------- | ----------- |
| Подъем гантелей стоя | 17.5х2-20х2 | 12-11-10-10 |
| Отжимания на трицепс в кроссовере | 50-55 | 12-12-12-12 |
| Бицепс в тренажере | 20-25 | 12-10-8-7 |
| Жим от плеч вверх в тренажере (кирпичики) | 40-45 | 12-12-9-8 |
| Махи гантелей в стороны стоя | 12.5х2 | 12-12-12-12 |
# 2026-07-13 (solo-75)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | --------- | ---------- |
| Подтягивания | 78.1 | 12-10-8-7 |
| Тяга вертикального блока узким хватом | 50-55 | 12-10-10-8 |
| Жим от груди сидя в тренажере (блинчики) | 25х2-30х2 | 12-10-8-6 |
| Брусья | 78.1 | 10-8-10-8 |
| Пресс на скамье | | 12-12-12 |
# 2026-07-11 (solo-74)
| Упражнение | Вес | Подходы |
| ------------------------------------------- | -------- | ----------- |
| Подъем штанги стоя | 35 | 10-9-8-7 |
| Французский жим лежа (штанга) | 30 | 10-10-9-7 |
| Поворот гири на лямке | 10х2 | 12-12-12-12 |
| Разведение ног сидя в тренажёре (кирпичики) | 80-80-85 | 12-12-12-12 |
| Икроножные сидя | 40-50-55 | 12-12-12-12 |
# 2026-07-09 (solo-73)
| Упражнение | Вес | Подходы |
| ----------------------------------------- | ------ | ----------- |
| Подтягивания | 78.1 | 12-9-8-7 |
| Тяга Т-грифа | 40 | 10-10-10-10 |
| Жим от груди сидя в тренажере (кирпичики) | 70-80 | 12-12-8-10 |
| Жим от груди лежа (гантели) | 22.5х2 | 10-10-10-10 |
| Пресс на скамье | | 12-12-12-12 |
# 2026-07-06 (solo-72)
| Упражнение | Вес | Подходы |
| ----------------------------------------- | ------- | ----------- |
| Разгибание ног сидя | 100-110 | 12-10-12-10 |
| Гиперэкстензия | 15 | 12-12-9-9 |
| Подъем гантелей стоя | 17.5х2 | 12-12-10-10 |
| Французский жим лежа (штанга) | 30 | 10-9-8-7 |
| Жим от плеч вверх в тренажере (кирпичики) | 50-45 | 7-9 |
# 2026-07-04 (solo-71)
| Упражнение | Вес | Подходы |
| ----------------------------------------- | ----- | ----------- |
| Подтягивания | 78.4 | 10-8-7-6 |
| Гребная тяга с упором в грудь | 80-85 | 12-10-10-10 |
| Жим от груди сидя в тренажере (кирпичики) | 70-75 | 12-10-10-7 |
| Сведение рук пек дек | 55 | 12-10-10-10 |
| Пресс на скамье | | 14-12-12 |
# 2026-07-02 (solo-70)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ---- | ----------- |
| Бицепс в тренажере | 25 | 12-8-8-7 |
| Французский жим лежа (штанга) | 30 | 10-9-8-8 |
| Разведение рук с гантелей из-за головы | 25 | 9-10-10-8 |
| Жим от плеч вверх (гантели) | 20х2 | 12-10-10-8 |
| Поворот гири на лямке | 10х2 | 12-12-11-11 |
# 2026-06-29 (solo-69)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ---- | ----------- |
| Подтягивания | 78.1 | 12-11-9-8 |
| Тяга вертикального блока узким хватом | 50 | 12-10-11-10 |
| Жим от груди сидя в тренажере (блинчики) | 25х2 | 12-12-10-9 |
| Сведение рук пек дек | 55 | 11-11-10-11 |
| Пресс на скамье | | 12-12-12-12 |
# 2026-06-26 (solo-68)
| Упражнение | Вес | Подходы |
| ----------------------------- | ------ | ----------- |
| Подъем гантелей стоя | 17.5х2 | 12-12-12-12 |
| Молоты стоя | 17.5х2 | 12-12-12-12 |
| Французский жим лежа (штанга) | 30 | 9-9-7-7 |
| Махи гантелей в стороны стоя | 15х2 | 12-11-11-10 |
| Поворот гири на лямке | 10х2 | 12-12-12-12 |
# 2026-06-24 (solo-67)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ----- | ----------- |
| Подтягивания | 77.5 | 12-10-8-7 |
| Тяга горизонтального блока | 55-65 | 12-9-9-9 |
| Сведение рук пек дек | 50-55 | 12-12-12-10 |
| Жим от груди сидя в тренажере (блинчики) | 25х2 | 9-10-8-10 |
| Пресс на скамье | | 12-12-12-12 |
# 2026-06-22 (solo-66)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ------ | ----------- |
| Подъем штанги стоя | 30-35 | 12-7-8-7 |
| Бицепс в тренажере | 25-20 | 10-10-10-9 |
| Французский жим лежа (штанга) | 30 | 11-10-8-6 |
| Разведение рук с гантелей из-за головы | 25 | 10-10-9-8 |
| Махи гантелей в стороны стоя | 12.5х2 | 12-12-12-12 |
# 2026-06-22 (solo-66)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ------ | ----------- |
| Подъем штанги стоя | 30-35 | 12-7-8-7 |
| Бицепс в тренажере | 25-20 | 10-10-10-9 |
| Французский жим лежа (штанга) | 30 | 11-10-8-6 |
| Разведение рук с гантелей из-за головы | 25 | 10-10-9-8 |
| Махи гантелей в стороны стоя | 12.5х2 | 12-12-12-12 |
# 2026-06-20 (solo-65)
| Упражнение | Вес | Подходы |
| ------------------------------------------- | ----- | ----------- |
| Подтягивания | 78 | 12-10-8-7 |
| Тяга Т-грифа | 30-40 | 12-10-10-10 |
| Сгибание ног лежа | 45-50 | 12-11-10-7 |
| Разведение ног сидя в тренажёре (кирпичики) | 50-80 | 12-12-12-12 |
| Икроножные сидя | 40-45 | 12-12-12-12 |
# 2026-06-17 (solo-64)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | -------- | ----------- |
| Подъем штанги стоя | 30-35-30 | 12-7-11-9 |
| Разведение рук с гантелей из-за головы | 22.5-25 | 12-12-12-12 |
| Отжимания на трицепс в кроссовере | 55 | 12-12-11-10 |
| Махи рукой в кроссовере | 20х2 | 12-12-12-12 |
| Жим от плеч вверх в тренажере (блинчики) | 15х2 | 12-11-6 |
# 2026-06-15 (solo-63)
| Упражнение | Вес | Подходы |
| ----------------------------------------- | -------- | ----------- |
| Подтягивания | 78.5 | 13-9-8-7 |
| Тяга вертикального блока узким хватом | 50-60-55 | 12-8-9-9 |
| Жим от груди сидя в тренажере (кирпичики) | 65-75-80 | 12-12-11-8 |
| Гиперэкстензия | 15 | 12-12-12-10 |
| Разгибание ног сидя | 90-100 | 12-12-9-10 |
# 2026-06-11 (solo-62)
| Упражнение | Вес | Подходы |
| ----------------------------------------- | ---- | ----------- |
| Подтягивания обратным хватом | 78 | 10-9-8-6 |
| Бицепс в тренажере | 25 | 7-8-7-7 |
| Французский жим лежа (штанга) | 30 | 11-9-8-9 |
| Махи рукой в кроссовере | 20х2 | 12-12-12-12 |
| Жим от плеч вверх в тренажере (кирпичики) | 40 | 12-11-11-7 |
# 2026-06-08 (solo-61)
| Упражнение | Вес | Подходы |
| ----------------------------------------- | -------- | ----------- |
| Подтягивания | 78.1 | 13-11-9-6 |
| Гребная тяга с упором в грудь | 60-70-75 | 15-12-12-12 |
| Жим от груди сидя в тренажере (кирпичики) | 70-75 | 12-12-10-8 |
| Сведение рук пек дек | 60 | 8-8-8-9 |
| Подъем ног в висе | 78.1 | 10-10-7 |
# 2026-06-06 (solo-60)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ----------- | --------- |
| Подъем штанги стоя | 30-35 | 12-7-7-7 |
| Молоты стоя | 17.5х2-20х2 | 12-9-9-10 |
| Разведение рук с гантелей из-за головы | 25-27.5 | 12-8-8-7 |
| Жим от плеч вверх в тренажере (блинчики) | 15х2 | 12-10-9-8 |
| Махи гантелей в стороны стоя | 12.5x2-15x2 | 12-11-9 |
# 2026-06-04 (solo-59)
| Упражнение | Вес | Подходы |
| ------------------------ | ----------- | ----------- |
| Подтягивания | 77.9 | 11-8-8-6 |
| Тяга вертикального блока | 50-55 | 12-11-11-11 |
| Тяга Т-грифа | 35-40 | 12-10-11-11 |
| Сгибание ног лежа | 50 | 12-12-11-9 |
| Икроножные сидя | 40-45-45-50 | 12-11-12-12 |
# 2026-06-01 (solo-58)
| Упражнение | Вес | Подходы |
| -------------------------------------- | -------- | ----------- |
| Подтягивания обратным хватом | 78.3 | 10-9-6-6 |
| Бицепс в тренажере | 25-25-20 | 9-7-11-10 |
| Разведение рук с гантелей из-за головы | 22.5 | 10-10-10-10 |
| Брусья (гравитрон) | 40-30 | 12-11-10-9 |
| Поворот гири на лямке | 10х2 | 12-12-11 |
# 2026-05-30 (solo-57)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ----------- | --------- |
| Подтягивания | 78.2 | 11-5-5-6 |
| Тяга горизонтального блока | 60-70-65-70 | 12-7-10-7 |
| Отведение рук пек дек | 30-35 | 12-9-10-9 |
| Сведение рук пек дек | 65-70-65 | 12-7-11-7 |
| Жим от груди сидя в тренажере (блинчики) | 20х2 | 12-12-12 |
# 2026-05-28 (solo-56)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | -------- | ----------- |
| Подъем штанги стоя | 30-35-30 | 12-6-12-9 |
| Французский жим лежа (штанга) | 30 | 8-8-7-7 |
| Разведение рук с гантелей из-за головы | 20-22.5 | 11-9-9-8 |
| Жим от плеч вверх в тренажере (блинчики) | 15х2 | 10-10-9-9 |
| Махи рукой в кроссовере | 20х2 | 12-12-12-12 |
# 2026-05-25 (solo-55)
| Упражнение | Вес | Подходы |
| ------------------- | -------- | ----------- |
| Подтягивания | 77.8 | 11-8-7-7 |
| Тяга Т-грифа | 40-45 | 12-9-9-10 |
| Икроножные сидя | 30-40-45 | 12-12-12-12 |
| Разгибание ног сидя | 90-100 | 12-12-11-12 |
| Пресс на скамье | | 13-12 |
# 2026-05-23 (solo-54)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | -------- | ----------- |
| Бицепс в тренажере | 25 | 10-8-8-8 |
| Подъем гантелей стоя обратным хватом | 17.5х2 | 12-12-12-12 |
| Французский жим лежа (штанга) | 30-30-25 | 8-8-11-8 |
| Жим от плеч вверх в тренажере (блинчики) | 15х2 | 10-8-9-7 |
| Поворот гири на лямке | 10х2 | 12-10-10-11 |
# 2026-05-21 (solo-53)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | -------------- | ----------- |
| Подтягивания | 77.8 | 11-9-6-7 |
| Тяга вертикального блока узким хватом | 55 | 11-10-9-7 |
| Жим от груди сидя в тренажере (блинчики) | 30х2-30х2-35х2 | 10-10-7-6 |
| Сведение рук пек дек | 55 | 11-11-10-10 |
| Махи рукой в кроссовере | 22х2-21х2 | 10-10-10-11 |
# 2026-05-18 (solo-52)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ------ | ----------- |
| Подъем штанги стоя | 35-30 | 10-12-10-10 |
| Молоты стоя | 17.5х2 | 12-11-12-12 |
| Французский жим лежа (штанга) | 30 | 10-10-8-7 |
| Разведение рук с гантелей из-за головы | 22.5 | 9-10-9-8 |
| Пресс на скамье | 79.1 | 13-12-12-12 |
# 2026-05-16 (solo-51)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ----------- | ----------- |
| Подтягивания | 78 | 12-8-8-6 |
| Тяга Т-грифа | 40-45 | 12-9-8-8 |
| Жим от груди лежа (гантели) | 25х2-27.5х2 | 12-8-8-7 |
| Жим от груди сидя в тренажере (блинчики) | 30х2 | 8-7-7-7 |
| Махи рукой в кроссовере | 20х2 | 12-12-11-12 |
# 2026-05-14 (solo-50)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ----------- | ----------- |
| Подъем гантелей стоя | 17.5х2-20х2 | 12-12-9-10 |
| Отжимания на трицепс в кроссовере | 65-65-60 | 10-10-12-10 |
| Разведение рук с гантелей из-за головы | 22.5 | 10-10-9-8 |
| Жим от плеч вверх (гантели) | 22.5х2 | 7-8-8-7 |
| Поворот гири на лямке | 10х2 | 12-11-10-11 |
# 2026-05-11 (solo-49)
| Упражнение | Вес | Подходы |
| -------------------------- | ----- | ----------- |
| Подтягивания | 77.6 | 10-9-7-7 |
| Тяга горизонтального блока | 60-65 | 12-10-10-10 |
| Брусья | 77.6 | 10-10-9-6 |
| Сведение рук пек дек | 65 | 9-7-7-6 |
| Пресс на скамье | | 12-12-12-12 |
# 2026-05-09 (solo-48)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ------------------ | ----------- |
| Подъем гантелей стоя | 17.5х2-17.5х2-20х2 | 12-12-8-8 |
| Бицепс в тренажере | 25-20 | 8-10-10-10 |
| Французский жим лежа (штанга) | 30 | 9-9-7-7 |
| Жим от плеч вверх в тренажере (блинчики) | 17.5х2 | 7-7-7-7 |
| Махи рукой в кроссовере | 20х2 | 12-12-12-12 |
# 2026-05-07 (solo-47)
| Упражнение | Вес | Подходы |
| ----------------------------------------- | ------- | ---------- |
| Подтягивания | 78 | 12-10-7-6 |
| Тяга вертикального блока узким хватом | 50-55 | 12-8-8-8 |
| Приседания | 30-32.5 | 12-9-8-8 |
| Икроножные сидя | 45 | 10-13-9-12 |
| Жим от груди сидя в тренажере (кирпичики) | 70-80 | 12-11-10 |
# 2026-05-04 (solo-46)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ------- | ---------- |
| Подъем штанги стоя | 35 | 12-10-8-8 |
| Подъем гантелей стоя | 17.5х2 | 10-10-10-9 |
| Разведение рук с гантелей из-за головы | 22.5-25 | 12-11-11-8 |
| Жим от плеч вверх (гантели) | 22.5х2 | 9-10-8-8 |
| Махи гантелей в стороны стоя | 15х2 | 12-11-11 |
# 2026-05-02 (solo-46)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | --------------- | ---------- |
| Подтягивания | 77.4 | 10-8-6-7 |
| Тяга Т-грифа | 35-45 | 12-7-8-8 |
| Жим от груди сидя в тренажере (блинчики) | 15х2-25х2-35х2 | 15-12-6-6 |
| Сведение рук пек дек | 60 | 10-9-9-9 |
| Гакк-приседания | 0-5х2-10х2-15х2 | 12-12-12-9 |
# 2026-04-30 (solo-45)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ----------- | ----------- |
| Подтягивания обратным хватом | 77.6 | 9-7-7-7 |
| Бицепс в тренажере | 25 | 8-7-8-7 |
| Французский жим лежа (штанга) | 30 | 9-8-7-6 |
| Жим от плеч вверх в тренажере (блинчики) | 15х2-17.5х2 | 11-8-8-6 |
| Поворот гири на лямке | 8х2 | 12-12-12-12 |
# 2026-04-27 (solo-44)
| Упражнение | Вес | Подходы |
| -------------------------- | -------- | ---------- |
| Подтягивания | 78.1 | 12-9-7-6 |
| Тяга горизонтального блока | 65-70-65 | 12-7-11-9 |
| Сгибание ног сидя | 50-60 | 12-12-10-9 |
| Икроножные сидя | 35-40-45 | 12-12-10-9 |
| Махи рукой в кроссовере | 20х2 | 12-12-12 |
# 2026-04-25 (solo-43)
| Упражнение | Вес | Подходы |
| ----------------------------- | ------------------ | ----------- |
| Подъем штанги стоя | 30 | 12-12-11-12 |
| Молоты стоя | 17.5х2-20х2-17.5x2 | 12-8-12-12 |
| Французский жим лежа (штанга) | 30-23-28 | 12-12-8-7 |
| Гиперэкстензия | 15 | 12-12-10-9 |
| Пресс на скамье | | 12-12-12-12 |
# 2026-04-23 (solo-42)
| Упражнение | Вес | Подходы |
| ------------------- | ----------- | ----------- |
| Разгибание ног сидя | 90-100-110 | 12-12-10-11 |
| Жим ногами | 100-110-120 | 12-12-9-10 |
| Сгибание ног лежа | 40-55 | 12-11-9-8 |
| Икроножные сидя | 20-25-30-35 | 12-12-12-12 |
| Пресс на скамье | | 12-12-12-12 |
# 2026-04-20 (solo-41)
| Упражнение | Вес | Подходы |
| ----------------------------------------- | ----------- | ----------- |
| Подтягивания | 78.0 | 10-9-5-7 |
| Тяга Т-грифа | 30-40-45-40 | 12-11-8-12 |
| Жим от груди лежа (гантели) | 25х2-30х2 | 12-7-7-5 |
| Жим от груди сидя в тренажере (кирпичики) | 75 | 9-9-9-8 |
| Пресс на скамье | 78.0 | 12-12-12-12 |
# 2026-04-18 (solo-40)
| Упражнение | Вес | Подходы |
| -------------------------------------- | --------- | ----------- |
| Подтягивания обратным хватом | 78.3 | 9-8-7-6 |
| Подъем гантелей стоя | 20х2 | 8-8-7-6 |
| Французский жим лежа (штанга) | 30 | 10-8-7-7 |
| Разведение рук с гантелей из-за головы | 22.5 | 10-7-9-9 |
| Махи рукой в кроссовере | 20х2-22х2 | 12-10-10-12 |
# 2026-04-16 (solo-39)
| Упражнение | Вес | Подходы |
| ----------------------------------------- | -------------- | --------- |
| Подтягивания | 77.7 | 12-7-8-8 |
| Тяга вертикального блока (блинчики) | 20х2-25х2 | 12-7-7-7 |
| Сведение рук пек дек | 70 | 10-10-9-7 |
| Жим от груди сидя в тренажере (кирпичики) | 70-75 | 12-8-9-9 |
| Махи рукой в кроссовере | 17х2-18х2-19х2 | 12-12-11 |
# 2026-04-13 (solo-38)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ----------- | --------- |
| Подтягивания обратным хватом | 78.7 | 10-8-7-6 |
| Бицепс в тренажере | 25-20 | 8-10-9-8 |
| Жим от плеч вверх в тренажере (блинчики) | 17.5х2 | 11-10-7-8 |
| Французский жим лежа (гантели) | 15х2 | 7-6-5-6 |
| Махи гантелей в стороны стоя | 17.5х2-15х2 | 9-10-10-9 |
# 2026-04-11 (solo-37)
| Упражнение | Вес | Подходы |
| --------------------------- | ----------- | ----------- |
| Подтягивания | 77.8 | 13-9-7-6 |
| Тяга гантели в наклоне | 32.5х2-35х2 | 12-12-12-9 |
| Жим от груди лежа (гантели) | 25х2 | 11-10-8-7 |
| Сведение рук пек дек | 65 | 7-7-7-7 |
| Пресс на скамье | | 13-12-12-12 |
# 2026-04-09 (solo-36)
| Упражнение | Вес | Подходы |
| -------------------------------------- | ----------------------- | ----------- |
| Подъем гантелей стоя | 20х2-22.5x2-22.5х2-20х2 | 12-7-7-7 |
| Французский жим лежа (штанга) | 30-30-25 | 7-6-10-10 |
| Разведение рук с гантелей из-за головы | 22.5 | 10-10-10-12 |
| Жим от плеч вверх (гантели) | 20х2-22.5х2 | 12-10-9-7 |
| Махи гантелей в стороны стоя | 15х2-17.5x2 | 12-8-9-10 |
# 2026-04-06 (solo-35)
| Упражнение | Вес | Подходы |
| --------------------------- | ----------- | --------- |
| Подтягивания | 78.1 | 12-10-8-7 |
| Тяга вертикального блока | 50-55 | 12-9-9-10 |
| Жим от груди лежа (гантели) | 25х2-27.5х2 | 12-10-8-7 |
| Сведение рук пек дек | 65 | 10-9-10-8 |
| Брусья | 78.1 | 9-7-8-6 |
# 2026-04-04 (solo-34)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | ---------------- | ----------- |
| Бицепс в тренажере | 30-25 | 7-8-8-6 |
| Молоты стоя | 20х2-20х2-17.5х2 | 12-10-12-12 |
| Французский жим лежа (штанга) | 30 | 8-8-7-7 |
| Жим от плеч вверх в тренажере (блинчики) | 15х2 | 11-11-8-9 |
| Махи гантелей в стороны стоя | 15х2 | 10-12-12-12 |
# 2026-04-02 (solo-33)
| Упражнение | Вес | Подходы |
| ---------------------------------------- | --------- | ----------- |
| Подтягивания | 78.1 | 12-8-8-6 |
| Тяга Т-грифа | 30-40 | 12-11-11-10 |
| Жим от груди сидя в тренажере (блинчики) | 25х2-30х2 | 12-11-9-8 |
| Брусья | 78.1 | 9-7-8-8 |
| Махи рукой в кроссовере | 17х2 | 12-12-12-12 |
# 2026-03-30 (solo-32)
| Упражнение | Вес | Подходы |
| ------------------------------------ | ----------- | ----------- |
| Подъем штанги стоя | 35 | 12-12-12-9 |
| Французский жим лежа (штанга) | 30 | 11-9-8-6 |
| Подъем гантелей стоя обратным хватом | 17.5х2-20х2 | 12-12-12-9 |
| Жим от плеч вверх (гантели) в наклоне | 20х2-22.5х2 | 12-10-8-8 |
| Махи гантелей в стороны стоя | 15х2 | 12-12-12-12 |
# 2026-03-26 (solo-31)
| Упражнение | Вес | Подходы |
| -------------------------------- | ---------------- | ----------- |
| Подтягивания | | 13-8-7-6 |
| Тяга гантели в наклоне | 25х2-30х2-32.5х2 | 12-12-12-10 |
| Жим лежа (штанга) | 50-70 | 12-6-5-5 |
| Сведение рук пек дек | 60-65 | 12-8-9-8 |
| Махи руки в сторону в кроссовере | 15х2-16х2 | 12-12-12-12 |
# 2026-03-23 (solo-30) # 2026-03-23 (solo-30)
| Упражнение | Вес | Подходы | | Упражнение | Вес | Подходы |
@ -1322,7 +2111,7 @@
| Гребная тяга с упором в грудь | 75 | 12-12-12-12 | | Гребная тяга с упором в грудь | 75 | 12-12-12-12 |
| Тяга Т-грифа | 32.5-35 | 12-12-12-12 | | Тяга Т-грифа | 32.5-35 | 12-12-12-12 |
| Жим от груди лежа (гантели) | 22.5х2 | 12-12-12-10 | | Жим от груди лежа (гантели) | 22.5х2 | 12-12-12-10 |
| махи рукой в кроссовере | 10x2 | 12-12-12-12 | | Махи рукой в кроссовере | 10x2 | 12-12-12-12 |
# 2025-04-26 (solo-41) # 2025-04-26 (solo-41)
@ -1459,7 +2248,7 @@
| ----------------------------- | -------- | ----------- | | ----------------------------- | -------- | ----------- |
| Подтягивания | | 10-7-6-5 | | Подтягивания | | 10-7-6-5 |
| Гребная тяга с упором в грудь | 55-65-70 | 12-12-12-12 | | Гребная тяга с упором в грудь | 55-65-70 | 12-12-12-12 |
| Тяга штанги в наклоне | 15х2 | 12-12-12-12 | | Тяга штанги в наклоне | 30 | 12-12-12-12 |
| Махи гантелей в стороны стоя| 10х2 | 12-12-12-12 | | Махи гантелей в стороны стоя| 10х2 | 12-12-12-12 |
| Жим от плеч вверх (гантели)| 20х2 | 10-10-8-8 | | Жим от плеч вверх (гантели)| 20х2 | 10-10-8-8 |
@ -1498,7 +2287,7 @@
| Упражнение | Вес | Подходы | | Упражнение | Вес | Подходы |
| ----------------------------- | --------- | ----------- | | ----------------------------- | --------- | ----------- |
| Подтягивания | | 10-7-6 | | Подтягивания | | 10-7-6 |
| Тяга штанги в наклоне | 10х2-15х2 | 12-12-12-12 | | Тяга штанги в наклоне | 20-30 | 12-12-12-12 |
| Тяга гантели в наклоне | 25х2 | 12-12-12-12 | | Тяга гантели в наклоне | 25х2 | 12-12-12-12 |
| Разведение рук пек дек | 30 | 12-12-10-8 | | Разведение рук пек дек | 30 | 12-12-10-8 |
| Жим от плеч вверх в тренажере (кирпичики)| 35 | 12-12-11-9 | | Жим от плеч вверх в тренажере (кирпичики)| 35 | 12-12-11-9 |
@ -1612,7 +2401,7 @@
| ---------------------- | ----------- | ----------- | | ---------------------- | ----------- | ----------- |
| Разгибание ног сидя | 75-75-90 | 12-16-12-12 | | Разгибание ног сидя | 75-75-90 | 12-16-12-12 |
| Жим ногами | 100-120-140 | 12-12-12-12 | | Жим ногами | 100-120-140 | 12-12-12-12 |
| Икроножные стоя | 25-40-45 | 12-12-12-12 | | Икроножные стоя | 12.5х2-20х2-22.5х2 | 12-12-12-12 |
| Жим от плеч вверх (гантели) | 20х2 | 9-12-10-8 | | Жим от плеч вверх (гантели) | 20х2 | 9-12-10-8 |
| Разведение рук пек дек | 35 | 12-12-12 | | Разведение рук пек дек | 35 | 12-12-12 |
@ -1692,7 +2481,7 @@
| --------------------------------------- | --------- | -------- | | --------------------------------------- | --------- | -------- |
| Болгарские выпады | 12х2-20х2 | 8-6-6 | | Болгарские выпады | 12х2-20х2 | 8-6-6 |
| Сгибание ног сидя | 77-86 | 12-12-12 | | Сгибание ног сидя | 77-86 | 12-12-12 |
| Икроножные стоя | 34 | 11-10-12 | | Икроножные стоя | 17х2 | 11-10-12 |
| Жим от плеч вверх в тренажере (блинчики) | 13x2-17x2 | 12-12-12 | | Жим от плеч вверх в тренажере (блинчики) | 13x2-17x2 | 12-12-12 |
| Дельт-машина | 47 | 12-12-12 | | Дельт-машина | 47 | 12-12-12 |
| Разведение рук пек дек | 37 | 12-9 | | Разведение рук пек дек | 37 | 12-9 |
@ -1816,7 +2605,7 @@
| Разгибание ног сидя | 49-81-90 | 12-12-12 | | Разгибание ног сидя | 49-81-90 | 12-12-12 |
| Жим ногами | 75-125-150 | 12-12-10 | | Жим ногами | 75-125-150 | 12-12-10 |
| Гиперэкстензия | 10-15 | 12-12-12 | | Гиперэкстензия | 10-15 | 12-12-12 |
| Махи рукой в кроссовере | 13.9 | 12-12-12 | | Махи рукой в кроссовере | 13.9х2 | 12-12-12 |
| Дельт-машина | 47 | 12-11-12 | | Дельт-машина | 47 | 12-11-12 |
| Разведение рук пек дек | 37 | 9-9-8 | | Разведение рук пек дек | 37 | 9-9-8 |
@ -1960,7 +2749,7 @@
| Упражнение | Вес | Подходы | | Упражнение | Вес | Подходы |
| -------------------------- | --------- | -------- | | -------------------------- | --------- | -------- |
| Подтягивания | | 10-7-5 | | Подтягивания | | 10-7-5 |
| Тяга гантели в наклоне | 24-26 | 12-12-12 | | Тяга гантели в наклоне | 24х2-26х2 | 12-12-12 |
| Жим от груди лежа (гантели) | 20х2-22х2 | 12-12-9 | | Жим от груди лежа (гантели) | 20х2-22х2 | 12-12-9 |
| Тяга горизонтального блока | 47-47-57 | 12-12-12 | | Тяга горизонтального блока | 47-47-57 | 12-12-12 |
| Тяга вертикального блока | 47 | 12-12-12 | | Тяга вертикального блока | 47 | 12-12-12 |
@ -2024,7 +2813,7 @@
| ----------------------------------------- | ----------- | ---------- | | ----------------------------------------- | ----------- | ---------- |
| Подтягивания | | 9-7-6 | | Подтягивания | | 9-7-6 |
| Тяга горизонтального блока | 57 | 12-12-12 | | Тяга горизонтального блока | 57 | 12-12-12 |
| Тяга гантели в наклоне | 26 | 12-12-12 | | Тяга гантели в наклоне | 26х2 | 12-12-12 |
| Жим от груди сидя в тренажере (блинчики)| 20x2-30x2-30x2-20x2 | 12-11-9-10 | | Жим от груди сидя в тренажере (блинчики)| 20x2-30x2-30x2-20x2 | 12-11-9-10 |
| Махи гантелей в стороны стоя| 9х2 | 12-12-12 | | Махи гантелей в стороны стоя| 9х2 | 12-12-12 |
| Трицепс машина | 77-87 | 12-12-9 | | Трицепс машина | 77-87 | 12-12-9 |
@ -2088,7 +2877,7 @@
| ---------------------------- | -------------- | --------- | | ---------------------------- | -------------- | --------- |
| Подтягивания | | 10-6-6 | | Подтягивания | | 10-6-6 |
| Гребная тяга с упором в грудь | 47-51 | 12-12-12 | | Гребная тяга с упором в грудь | 47-51 | 12-12-12 |
| Тяга гантели в наклоне | 26-28 | 12-12-12 | | Тяга гантели в наклоне | 26х2-28х2 | 12-12-12 |
| Жим от груди лежа (гантели) | 18х2-24х2-22х2 | 12-10-7-8 | | Жим от груди лежа (гантели) | 18х2-24х2-22х2 | 12-10-7-8 |
| Сведение рук пек дек| 37 | 12-12-12 | | Сведение рук пек дек| 37 | 12-12-12 |
| Брусья | 37 | 12-9-9 | | Брусья | 37 | 12-9-9 |
@ -2098,7 +2887,7 @@
| Упражнение | Вес | Подходы | | Упражнение | Вес | Подходы |
| ----------------------------- | -------- | ----------- | | ----------------------------- | -------- | ----------- |
| Разгибание ног сидя | 62-82-90 | 12-12-11-10 | | Разгибание ног сидя | 62-82-90 | 12-12-11-10 |
| Румынская тяга | 40 | 12-12-12 | | Румынская тяга | 20х2 | 12-12-12 |
| Икроножные сидя | 27 | 16-15-14 | | Икроножные сидя | 27 | 16-15-14 |
| Разведение рук пек дек | 47 | 12-12-12 | | Разведение рук пек дек | 47 | 12-12-12 |
| Жим от плеч вверх (гантели) | 18х2 | 11-12-12 | | Жим от плеч вверх (гантели) | 18х2 | 11-12-12 |
@ -2119,7 +2908,7 @@
| Упражнение | Вес | Подходы | | Упражнение | Вес | Подходы |
| ------------------------ | ---------- | ----------- | | ------------------------ | ---------- | ----------- |
| Разгибание ног сидя | 59-77-81.5 | 12-12-12-12 | | Разгибание ног сидя | 59-77-81.5 | 12-12-12-12 |
| Румынская тяга | 40 | 12-12-12 | | Румынская тяга | 20х2 | 12-12-12 |
| Икроножные сидя | 27.2 | 12-12-12 | | Икроножные сидя | 27.2 | 12-12-12 |
| Жим от плеч вверх (гантели) | 16x2-18x2 | 12-12-12 | | Жим от плеч вверх (гантели) | 16x2-18x2 | 12-12-12 |
| Дельт-машина | 47 | 12-12-10 | | Дельт-машина | 47 | 12-12-10 |

View file

@ -22,10 +22,6 @@ def dump_trainings_to_json(trainings: List[TrainingDTO], filename="trainings.jso
def main(): def main():
# Parse Obsidian notes # Parse Obsidian notes
obsidian_trainings = odp() obsidian_trainings = odp()
for training in obsidian_trainings:
for exercise in training.exercises:
print(exercise.name)
# Parse Apple notes # Parse Apple notes
apple_trainings = adp() apple_trainings = adp()

1
migrations/README Normal file
View file

@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.

View file

99
migrations/env.py Normal file
View file

@ -0,0 +1,99 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.config import settings
from app.core.database.models import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# The URL comes from app settings (.env), not from alembic.ini.
# "%" is escaped because the value goes through configparser interpolation.
config.set_main_option(
"sqlalchemy.url", settings.get_postgres_database_url().replace("%", "%%")
)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name, disable_existing_loggers=False)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection, target_metadata=target_metadata, compare_type=True
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -1,76 +0,0 @@
import logging
from pathlib import Path
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy import text
class MigrationRunner:
MIGRATIONS_SCHEMA_NAME: str = "schema_migrations"
def __init__(self, database_url: str) -> None:
self.engine = create_async_engine(database_url)
self.migrations_dir = Path("migrations/sql")
logging.info(
f"Migrator initializer: engine = {self.engine}, migrations_dir = {self.migrations_dir}"
)
async def get_applied_migrations(self) -> set:
"""Get list of applied migrations"""
async with self.engine.begin() as conn:
# Create migrations list table if not exists
await conn.execute(
text(f"""
CREATE TABLE IF NOT EXISTS {self.MIGRATIONS_SCHEMA_NAME} (
version VARCHAR(50) PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
)
# Receiving list of applied migrations
result = await conn.execute(
text(f"SELECT version FROM {self.MIGRATIONS_SCHEMA_NAME}")
)
result_data = {row[0] for row in result.fetchall()}
logging.info(f"Received migrator data: {result_data}")
return result_data
async def run_migrations(self):
"""Run all unapplied migrations"""
applied = await self.get_applied_migrations()
logging.info(f"Applied migrations: {applied}")
# Getting all sql files from migrations_dir
# TODO: (#ToLearn) Read about Path.glob function
migration_files = self.migrations_dir.glob('.*sql')
migration_files = sorted([file for file in self.migrations_dir.glob("*.sql")])
logging.info(f"Migration files: {migration_files}")
for migration_file in migration_files:
# TODO: (#ToLearn) Read about stem property
migration_name = migration_file.stem
if migration_name not in applied:
logging.info(f"Applying migration: {migration_name}")
await self._apply_migration(migration_file, migration_name)
else:
logging.info(f"Skipping migration: {migration_name} (already applied)")
async def _apply_migration(self, migration_file: Path, migration_name: str):
"""Apply migrations from migrations/sql folder"""
with open(migration_file, "r", encoding="utf-8") as f:
sql_content = f.read()
async with self.engine.begin() as conn:
try:
statements = [s.strip() for s in sql_content.split(";") if s.strip()]
for statement in statements:
if statement:
await conn.execute(text(statement))
logging.info(f"Migration {migration_name} applied successfully")
except Exception as e:
logging.error(f"Error applying migration {migration_name}: {e}")
raise

28
migrations/script.py.mako Normal file
View file

@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View file

@ -1,19 +0,0 @@
-- Migration: 0001_initial
-- Description: Create initial migration with user for f1tness app
-- Default user schema
-- TODO: (#ToLearn) Read about TIMESTAMP and CURRENT_TIMESTAMP
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(250) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Schema for tracking applied migrations
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(50) PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO schema_migrations (version) VALUES ('0001_initial');

View file

@ -1,28 +0,0 @@
-- Migration: 0002_trainings
-- Description: Basic migration to initialize and create basic tarinings table
-- Schemas for trainings
-- Trainings table
CREATE TABLE IF NOT EXISTS trainings (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
trainer VARCHAR(100)
);
-- Exercies table
CREATE TABLE IF NOT EXISTS exercises (
id SERIAL PRIMARY KEY,
training_id INTEGER REFERENCES trainings(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
splitted_weigh BOOLEAN DEFAULT FALSE
);
-- Approaches table
CREATE TABLE IF NOT EXISTS approaches (
id SERIAL PRIMARY KEY,
approach_id INTEGER REFERENCES approaches(id) ON DELETE CASCADE,
weight FLOAT NOT NULL,
reps INTEGER NOT NULL
);
INSERT INTO schema_migrations (version) VALUES ('0002_trainings');

View file

@ -1,37 +0,0 @@
-- Migration: 0003_fix_trainings_id
-- Description: Fix trainings table to use proper SQLite autoincrement ID
-- Drop the existing trainings table (data will be lost)
DROP TABLE IF EXISTS trainings;
-- Recreate with proper SQLite syntax
CREATE TABLE trainings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
trainer VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Also fix exercises table if it exists
DROP TABLE IF EXISTS exercises;
CREATE TABLE exercises (
id INTEGER PRIMARY KEY AUTOINCREMENT,
training_id INTEGER NOT NULL,
name VARCHAR(255) NOT NULL,
splitted_weight BOOLEAN DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (training_id) REFERENCES trainings(id) ON DELETE CASCADE
);
-- Fix approaches table if it exists
DROP TABLE IF EXISTS approaches;
CREATE TABLE approaches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
exercise_id INTEGER NOT NULL,
weight REAL,
reps INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE CASCADE
);
INSERT INTO schema_migrations (version) VALUES ('0003_fix_trainings_id');

View file

@ -0,0 +1,66 @@
"""initial schema
Revision ID: 08ba8bee6328
Revises:
Create Date: 2026-10-22 22:48:54.728389
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '08ba8bee6328'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('trainings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('trainer', sa.String(length=100), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_trainings_date'), 'trainings', ['date'], unique=False)
op.create_table('exercises',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('training_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('splitted_weight', sa.Boolean(), server_default='false', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['training_id'], ['trainings.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_exercises_name'), 'exercises', ['name'], unique=False)
op.create_index(op.f('ix_exercises_training_id'), 'exercises', ['training_id'], unique=False)
op.create_table('approaches',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('exercise_id', sa.Integer(), nullable=False),
sa.Column('weight', sa.Float(), nullable=False),
sa.Column('reps', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['exercise_id'], ['exercises.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_approaches_exercise_id'), 'approaches', ['exercise_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_approaches_exercise_id'), table_name='approaches')
op.drop_table('approaches')
op.drop_index(op.f('ix_exercises_training_id'), table_name='exercises')
op.drop_index(op.f('ix_exercises_name'), table_name='exercises')
op.drop_table('exercises')
op.drop_index(op.f('ix_trainings_date'), table_name='trainings')
op.drop_table('trainings')
# ### end Alembic commands ###

View file

@ -4,26 +4,11 @@ version = "0.1.0"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ # Dependencies live in requirements.txt / requirements-dev.txt
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"pydantic>=2.11.7",
"pydantic-settings>=2.0.0",
"pytest>=8.3.4",
"python-dotenv>=1.0.1",
"jinja2>=3.1.0",
"python-multipart>=0.0.6",
"sqlalchemy>=2.0.43",
"asyncpg>=0.30.0",
"aiosqlite>=0.21.0",
]
[dependency-groups] [tool.pytest.ini_options]
dev = [ asyncio_mode = "auto"
"mypy>=1.17.1", asyncio_default_fixture_loop_scope = "function"
"types-grpcio>=1.0.0.20250703",
"types-protobuf>=6.30.2.20250822",
]
[tools.pyright] [tools.pyright]
venvPath = "." venvPath = "."

7
requirements-dev.txt Normal file
View file

@ -0,0 +1,7 @@
-r requirements.txt
pytest==8.4.1
pytest-asyncio==1.4.0
mypy==1.17.1
types-grpcio==1.0.0.20250703
types-protobuf==6.30.2.20250822

10
requirements.txt Normal file
View file

@ -0,0 +1,10 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
pydantic==2.11.7
pydantic-settings==2.10.1
python-dotenv==1.1.1
jinja2==3.1.6
python-multipart==0.0.20
sqlalchemy[asyncio]==2.1.6
asyncpg==0.32.0
alembic==1.20.1

View file

@ -0,0 +1,67 @@
"""Shared fixtures for tests that need a real PostgreSQL.
In docker compose TEST_DATABASE_URL is already set (database `<name>_test`).
Elsewhere set it to a throwaway database whose name ends with `_test`; it is
created if missing and its tables are emptied by every test that uses `session`.
Without TEST_DATABASE_URL those tests are skipped.
"""
import asyncio
import os
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
from app.core.database.connection import create_engine, create_session_factory
from app.core.database.repository import TrainingRepository
TEST_DATABASE_URL = os.environ.get("TEST_DATABASE_URL")
async def ensure_test_database(url: str) -> None:
"""Create the test database if it doesn't exist yet."""
target = make_url(url)
name = target.database or ""
if not name.endswith("_test"):
pytest.fail(f"Refusing to run: database {name!r} must end with '_test'")
admin_engine = create_async_engine(
target.set(database="postgres"), isolation_level="AUTOCOMMIT"
)
try:
async with admin_engine.connect() as conn:
exists = await conn.scalar(
text("SELECT 1 FROM pg_database WHERE datname = :name"), {"name": name}
)
if not exists:
quoted = name.replace('"', '""')
await conn.execute(text(f'CREATE DATABASE "{quoted}"'))
finally:
await admin_engine.dispose()
@pytest.fixture
async def alembic_config(monkeypatch) -> Config:
if not TEST_DATABASE_URL:
pytest.skip("TEST_DATABASE_URL is not set")
await ensure_test_database(TEST_DATABASE_URL)
# migrations/env.py takes the URL from settings
monkeypatch.setattr(settings, "POSTGRES_DATABASE_URL", TEST_DATABASE_URL)
return Config("alembic.ini")
@pytest.fixture
async def session(alembic_config):
"""Session on a migrated and emptied test database."""
# env.py runs its own event loop, so alembic goes to a thread
await asyncio.to_thread(command.upgrade, alembic_config, "head")
engine = create_engine(TEST_DATABASE_URL)
async with create_session_factory(engine)() as session:
await TrainingRepository(session).delete_all()
yield session
await engine.dispose()

View file

@ -0,0 +1,116 @@
from datetime import date
import pytest
from app.core.dto.training import ApproachDTO, ExerciseSessionDTO
from app.core.metrics import estimate_1rm, session_metrics, summarize
def make_session(day: int, *sets: tuple, month: int = 1) -> ExerciseSessionDTO:
return ExerciseSessionDTO(
date=date(2025, month, day),
approaches=[ApproachDTO(weight=weight, reps=reps) for weight, reps in sets],
)
def test_estimate_1rm_uses_epley_formula():
assert estimate_1rm(140, 12) == pytest.approx(196)
assert estimate_1rm(100, 10) == pytest.approx(133.333, rel=1e-4)
def test_single_rep_is_the_weight_itself():
assert estimate_1rm(100, 1) == 100
def test_session_metrics_of_weighted_sets():
metrics = session_metrics(make_session(1, (100, 12), (110, 10)).approaches)
assert metrics.max_weight == 110
assert metrics.e1rm == pytest.approx(max(140.0, 110 * (1 + 10 / 30)))
assert metrics.volume == 100 * 12 + 110 * 10
assert metrics.total_reps == 22
assert metrics.best_reps == 12
def test_sets_without_weight_count_only_as_reps():
metrics = session_metrics(make_session(1, (0, 7), (47, 12)).approaches)
assert metrics.e1rm == pytest.approx(estimate_1rm(47, 12))
assert metrics.volume == 47 * 12
assert metrics.total_reps == 19
def test_session_without_weight_has_no_e1rm():
metrics = session_metrics(make_session(1, (0, 11), (0, 8)).approaches)
assert metrics.e1rm is None
assert metrics.volume == 0
assert metrics.total_reps == 19
assert metrics.best_reps == 11
def test_weighted_progress_records_and_change():
# newest first, like the repository returns
history = [
make_session(20, (100, 12)), # e1RM 140
make_session(10, (110, 12), (100, 12)), # e1RM 154 (record)
make_session(1, (80, 10)), # e1RM 106.67
]
progress = summarize(history)
assert progress.weighted is True
assert progress.max_weight == 110
assert progress.best_primary.value == pytest.approx(154)
assert progress.best_primary.date == date(2025, 1, 10)
assert progress.best_secondary.value == 110 * 12 + 100 * 12
assert progress.first_primary == pytest.approx(80 * (1 + 10 / 30))
assert progress.last_primary == pytest.approx(140)
assert progress.primary_change == pytest.approx(140 - 80 * (1 + 10 / 30))
assert progress.primary_change_percent == pytest.approx(
(140 - 80 * (1 + 10 / 30)) / (80 * (1 + 10 / 30)) * 100
)
def test_record_keeps_the_first_date_it_was_reached():
history = [make_session(20, (100, 12)), make_session(10, (100, 12))]
assert summarize(history).best_primary.date == date(2025, 1, 10)
def test_exercise_without_weight_is_tracked_by_reps():
history = [
make_session(20, (0, 8), (0, 7)), # best 8, total 15
make_session(10, (0, 10), (0, 5)), # best 10, total 15
make_session(1, (0, 6)), # best 6, total 6
]
progress = summarize(history)
assert progress.weighted is False
assert progress.max_weight == 0
assert progress.best_primary.value == 10
assert progress.best_primary.date == date(2025, 1, 10)
assert progress.best_secondary.value == 15
assert progress.best_secondary.date == date(2025, 1, 10)
assert (progress.first_primary, progress.last_primary) == (6, 8)
assert progress.primary_change == 2
def test_weighted_exercise_ignores_sessions_without_weight_for_primary_metric():
history = [
make_session(20, (0, 12)), # no e1RM
make_session(10, (100, 10)),
]
progress = summarize(history)
assert progress.weighted is True
assert progress.last_primary == pytest.approx(estimate_1rm(100, 10))
assert progress.primary_change == 0
def test_percent_change_is_undefined_when_the_first_value_is_zero():
progress = summarize([make_session(1, (0, 0))])
assert progress.primary_change_percent is None

View file

@ -0,0 +1,124 @@
from datetime import date
import pytest
from app.core.parsers.apple import AppleNotesParser
from app.core.parsers.obsidian import ObsidianNotesParser
from app.core.utils.date_refactor import parse_training_date
OBSIDIAN_NOTES = """\
# 07.01.2025 (solo-2)
| Упражнение | Вес | Подходы |
| --------------------------------------- | --------------------- | ----------- |
| Разгибание ног сидя | 77-86 | 12-12-12-12 |
| Подтягивания | красная резинка | 9-7-5 |
# 04.01.2025 (c153-1)
| Упражнение | Вес | Подходы |
| --- | --- | --- |
| Сгибание ног лежа | 47 | 12-12-11-10 |
"""
APPLE_NOTES = """\
**21.08.2024 (с123-69)**
| | | |
|---|---|---|
|**Упражнение**|**Вес**|**Подходы**|
|Жим ногами|100|12-12-12|
**22.08.2024 (соло)**
|Подтягивания||9-7-5|
"""
def test_obsidian_parses_every_training_and_trainer():
parser = ObsidianNotesParser()
trainings = parser.parse(OBSIDIAN_NOTES, map_names=False)
assert [t.date for t in trainings] == [date(2025, 1, 7), date(2025, 1, 4)]
assert [t.trainer for t in trainings] == ["solo", "c153"]
assert [len(t.exercises) for t in trainings] == [1, 1]
def test_malformed_rows_are_reported_not_silently_dropped():
parser = ObsidianNotesParser()
parser.parse(OBSIDIAN_NOTES, map_names=False)
assert len(parser.errors) == 1
assert "красная резинка" in parser.errors[0]
def test_errors_are_reset_between_parses():
parser = ObsidianNotesParser()
parser.parse(OBSIDIAN_NOTES, map_names=False)
parser.parse("# 07.01.2025 (solo-2)\n", map_names=False)
assert parser.errors == []
def test_last_line_header_keeps_all_trainings():
parser = ObsidianNotesParser()
text = OBSIDIAN_NOTES + "\n# 01.01.2025 (solo-3)"
trainings = parser.parse(text, map_names=False)
assert [t.date for t in trainings] == [
date(2025, 1, 7),
date(2025, 1, 4),
date(2025, 1, 1),
]
assert trainings[-1].exercises == []
def test_bad_date_skips_whole_training_block():
parser = ObsidianNotesParser()
text = "# 31.02.2025 (solo-1)\n| Жим | 10 | 12-12 |\n" + OBSIDIAN_NOTES
trainings = parser.parse(text, map_names=False)
assert [t.date for t in trainings] == [date(2025, 1, 7), date(2025, 1, 4)]
# The bad date itself plus the malformed row inside OBSIDIAN_NOTES;
# the exercise under the bad header is skipped without an extra error
assert len(parser.errors) == 2
assert "day is out of range" in parser.errors[0]
def test_exercise_mapping_is_applied_by_default():
parser = ObsidianNotesParser()
parser.exercise_mapper = {"Разгибание ног сидя": "Разгибание ног"}
mapped = parser.parse(OBSIDIAN_NOTES)
unmapped = parser.parse(OBSIDIAN_NOTES, map_names=False)
assert mapped[0].exercises[0].name == "Разгибание ног"
assert unmapped[0].exercises[0].name == "Разгибание ног сидя"
def test_apple_parses_headers_with_and_without_counter():
parser = AppleNotesParser()
trainings = parser.parse(APPLE_NOTES, map_names=False)
assert [t.date for t in trainings] == [date(2024, 8, 21), date(2024, 8, 22)]
assert [t.trainer for t in trainings] == ["с123", "соло"]
assert [len(t.exercises) for t in trainings] == [1, 1]
assert trainings[1].exercises[0].approaches[0].weight == 0.0
@pytest.mark.parametrize(
"raw, expected",
[
("21.08.2024", date(2024, 8, 21)),
("21.08.24", date(2024, 8, 21)),
("2024-08-21", date(2024, 8, 21)),
("2024.08.21", date(2024, 8, 21)),
],
)
def test_parse_training_date_formats(raw, expected):
assert parse_training_date(raw) == expected
def test_parse_training_date_rejects_garbage():
with pytest.raises(ValueError):
parse_training_date("yesterday")

View file

@ -1,7 +1,10 @@
from obsidian.notes_parser import serialize_exercise from app.core.dto.training import ApproachDTO as Approach, ExerciseDTO as Exercise
from obsidian.py_models import Approach, Exercise from app.core.parsers.base import ParseError
from app.core.parsers.obsidian import ObsidianNotesParser
import pytest import pytest
serialize_exercise = ObsidianNotesParser().serialize_exercise
@pytest.mark.parametrize( @pytest.mark.parametrize(
"reps, weight, name, expected", "reps, weight, name, expected",
@ -65,8 +68,51 @@ import pytest
], ],
), ),
), ),
(
# Cyrillic and latin "x" mixed in a single value
"12-12-12",
"10х2-12x2",
"test_exercise",
Exercise(
name="test_exercise",
splitted_weight=True,
approaches=[
Approach(weight=10, reps=12),
Approach(weight=12, reps=12),
Approach(weight=12, reps=12),
],
),
),
(
# First approach without weight, the rest per hand
"8-8-7",
"0-12х2-16х2",
"test_exercise",
Exercise(
name="test_exercise",
splitted_weight=True,
approaches=[
Approach(weight=0, reps=8),
Approach(weight=12, reps=8),
Approach(weight=16, reps=7),
],
),
),
], ],
) )
def test_approach_parsing(reps, weight, name, expected): def test_approach_parsing(reps, weight, name, expected):
exercise = serialize_exercise(reps, weight, name) exercise = serialize_exercise(reps, weight, name)
assert expected == exercise assert expected == exercise
@pytest.mark.parametrize(
"reps, weight",
[
("12-12-", "10х2"),
("11--11-7", ""),
("12-12", "красная резинка"),
],
)
def test_malformed_exercise_raises_parse_error(reps, weight):
with pytest.raises(ParseError):
serialize_exercise(reps, weight, "test_exercise")

View file

@ -0,0 +1,150 @@
"""Repository and migration tests against a real PostgreSQL.
The `session` / `alembic_config` fixtures (tests/conftest.py) skip these tests
when TEST_DATABASE_URL is not set.
"""
import asyncio
from datetime import date
import pytest
from alembic import command
from sqlalchemy import func, select
from sqlalchemy.exc import DBAPIError
from app.core.database.models import ApproachModel, ExerciseModel
from app.core.database.repository import TrainingRepository
from app.core.dto.training import ApproachDTO, ExerciseDTO, TrainingDTO
from app.core.parsers.apple import AppleNotesParser
from app.core.parsers.obsidian import ObsidianNotesParser
def make_training(day: int = 1, trainer: str = "solo") -> TrainingDTO:
return TrainingDTO(
date=date(2025, 1, day),
trainer=trainer,
exercises=[
ExerciseDTO(
name="Жим ногами",
approaches=[
ApproachDTO(weight=100, reps=12),
ApproachDTO(weight=110, reps=10),
],
),
ExerciseDTO(
name="Жим гантелей лежа",
splitted_weight=True,
approaches=[ApproachDTO(weight=20, reps=12)],
),
],
)
async def test_save_and_list_roundtrip(session):
repository = TrainingRepository(session)
training_id = await repository.save(make_training())
trainings = await repository.list()
assert len(trainings) == 1
saved = trainings[0]
assert saved.id == training_id
assert (saved.date, saved.trainer) == (date(2025, 1, 1), "solo")
assert [e.name for e in saved.exercises] == ["Жим ногами", "Жим гантелей лежа"]
assert saved.exercises[1].splitted_weight is True
assert [(a.weight, a.reps) for a in saved.exercises[0].approaches] == [
(100, 12),
(110, 10),
]
async def test_list_is_ordered_by_date_desc(session):
repository = TrainingRepository(session)
await repository.save_many([make_training(day=1), make_training(day=3)])
assert [t.date.day for t in await repository.list()] == [3, 1]
async def test_exercise_names_are_distinct_counted_and_sorted(session):
repository = TrainingRepository(session)
await repository.save_many([make_training(day=1), make_training(day=3)])
names = await repository.list_exercise_names()
assert [(item.name, item.count) for item in names] == [
("Жим гантелей лежа", 2),
("Жим ногами", 2),
]
async def test_exercise_history_is_newest_first(session):
repository = TrainingRepository(session)
await repository.save_many(
[make_training(day=1), make_training(day=3, trainer="c153")]
)
history = await repository.exercise_history("Жим ногами")
assert [item.date.day for item in history] == [3, 1]
assert [item.trainer for item in history] == ["c153", "solo"]
assert [(a.weight, a.reps) for a in history[0].approaches] == [(100, 12), (110, 10)]
assert history[0].splitted_weight is False
async def test_exercise_history_keeps_splitted_weight_and_ignores_other_exercises(
session,
):
repository = TrainingRepository(session)
await repository.save(make_training())
history = await repository.exercise_history("Жим гантелей лежа")
assert len(history) == 1
assert history[0].splitted_weight is True
assert [a.weight for a in history[0].approaches] == [20]
assert await repository.exercise_history("Несуществующее") == []
async def test_delete_cascades_to_exercises_and_approaches(session):
repository = TrainingRepository(session)
keep_id, delete_id = await repository.save_many(
[make_training(day=1), make_training(day=2)]
)
assert await repository.delete([delete_id]) == 1
assert [t.id for t in await repository.list()] == [keep_id]
assert await session.scalar(select(func.count(ExerciseModel.id))) == 2
assert await session.scalar(select(func.count(ApproachModel.id))) == 3
async def test_save_many_is_atomic(session):
repository = TrainingRepository(session)
broken = make_training(day=2, trainer="x" * 101) # trainer is VARCHAR(100)
with pytest.raises(DBAPIError):
await repository.save_many([make_training(day=1), broken])
await session.rollback()
assert await repository.count() == 0
async def test_all_notes_can_be_saved(session):
trainings = ObsidianNotesParser().parse_file() + AppleNotesParser().parse_file()
repository = TrainingRepository(session)
ids = await repository.save_many(trainings)
assert len(ids) == len(trainings)
assert await repository.count() == len(trainings)
async def test_migrations_match_models(alembic_config):
await asyncio.to_thread(command.upgrade, alembic_config, "head")
# check() raises if autogenerate would produce a new migration
await asyncio.to_thread(command.check, alembic_config)
async def test_downgrade_and_upgrade(alembic_config):
await asyncio.to_thread(command.downgrade, alembic_config, "base")
await asyncio.to_thread(command.upgrade, alembic_config, "head")

51701
trainings.json Normal file

File diff suppressed because it is too large Load diff

613
uv.lock
View file

@ -1,613 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "aiosqlite"
version = "0.21.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" },
]
[[package]]
name = "asyncpg"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746, upload-time = "2024-10-20T00:30:41.127Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373, upload-time = "2024-10-20T00:29:55.165Z" },
{ url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745, upload-time = "2024-10-20T00:29:57.14Z" },
{ url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103, upload-time = "2024-10-20T00:29:58.499Z" },
{ url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471, upload-time = "2024-10-20T00:30:00.354Z" },
{ url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253, upload-time = "2024-10-20T00:30:02.794Z" },
{ url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720, upload-time = "2024-10-20T00:30:04.501Z" },
{ url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404, upload-time = "2024-10-20T00:30:06.537Z" },
{ url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623, upload-time = "2024-10-20T00:30:09.024Z" },
]
[[package]]
name = "click"
version = "8.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "f1tness-parser"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "aiosqlite" },
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "jinja2" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pytest" },
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "mypy" },
{ name = "types-grpcio" },
{ name = "types-protobuf" },
]
[package.metadata]
requires-dist = [
{ name = "aiosqlite", specifier = ">=0.21.0" },
{ name = "asyncpg", specifier = ">=0.30.0" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "pydantic", specifier = ">=2.11.7" },
{ name = "pydantic-settings", specifier = ">=2.0.0" },
{ name = "pytest", specifier = ">=8.3.4" },
{ name = "python-dotenv", specifier = ">=1.0.1" },
{ name = "python-multipart", specifier = ">=0.0.6" },
{ name = "sqlalchemy", specifier = ">=2.0.43" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "mypy", specifier = ">=1.17.1" },
{ name = "types-grpcio", specifier = ">=1.0.0.20250703" },
{ name = "types-protobuf", specifier = ">=6.30.2.20250822" },
]
[[package]]
name = "fastapi"
version = "0.116.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" },
]
[[package]]
name = "greenlet"
version = "3.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
{ url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
{ url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
{ url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
{ url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httptools"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" },
{ url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" },
{ url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" },
{ url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" },
{ url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" },
{ url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" },
{ url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
]
[[package]]
name = "mypy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/22/ea637422dedf0bf36f3ef238eab4e455e2a0dcc3082b5cc067615347ab8e/mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01", size = 3352570, upload-time = "2025-07-31T07:54:19.204Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/82/aec2fc9b9b149f372850291827537a508d6c4d3664b1750a324b91f71355/mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7", size = 11075338, upload-time = "2025-07-31T07:53:38.873Z" },
{ url = "https://files.pythonhosted.org/packages/07/ac/ee93fbde9d2242657128af8c86f5d917cd2887584cf948a8e3663d0cd737/mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81", size = 10113066, upload-time = "2025-07-31T07:54:14.707Z" },
{ url = "https://files.pythonhosted.org/packages/5a/68/946a1e0be93f17f7caa56c45844ec691ca153ee8b62f21eddda336a2d203/mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6", size = 11875473, upload-time = "2025-07-31T07:53:14.504Z" },
{ url = "https://files.pythonhosted.org/packages/9f/0f/478b4dce1cb4f43cf0f0d00fba3030b21ca04a01b74d1cd272a528cf446f/mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849", size = 12744296, upload-time = "2025-07-31T07:53:03.896Z" },
{ url = "https://files.pythonhosted.org/packages/ca/70/afa5850176379d1b303f992a828de95fc14487429a7139a4e0bdd17a8279/mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14", size = 12914657, upload-time = "2025-07-31T07:54:08.576Z" },
{ url = "https://files.pythonhosted.org/packages/53/f9/4a83e1c856a3d9c8f6edaa4749a4864ee98486e9b9dbfbc93842891029c2/mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a", size = 9593320, upload-time = "2025-07-31T07:53:01.341Z" },
{ url = "https://files.pythonhosted.org/packages/38/56/79c2fac86da57c7d8c48622a05873eaab40b905096c33597462713f5af90/mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733", size = 11040037, upload-time = "2025-07-31T07:54:10.942Z" },
{ url = "https://files.pythonhosted.org/packages/4d/c3/adabe6ff53638e3cad19e3547268482408323b1e68bf082c9119000cd049/mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd", size = 10131550, upload-time = "2025-07-31T07:53:41.307Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c5/2e234c22c3bdeb23a7817af57a58865a39753bde52c74e2c661ee0cfc640/mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0", size = 11872963, upload-time = "2025-07-31T07:53:16.878Z" },
{ url = "https://files.pythonhosted.org/packages/ab/26/c13c130f35ca8caa5f2ceab68a247775648fdcd6c9a18f158825f2bc2410/mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a", size = 12710189, upload-time = "2025-07-31T07:54:01.962Z" },
{ url = "https://files.pythonhosted.org/packages/82/df/c7d79d09f6de8383fe800521d066d877e54d30b4fb94281c262be2df84ef/mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91", size = 12900322, upload-time = "2025-07-31T07:53:10.551Z" },
{ url = "https://files.pythonhosted.org/packages/b8/98/3d5a48978b4f708c55ae832619addc66d677f6dc59f3ebad71bae8285ca6/mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed", size = 9751879, upload-time = "2025-07-31T07:52:56.683Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f3/8fcd2af0f5b806f6cf463efaffd3c9548a28f84220493ecd38d127b6b66d/mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9", size = 2283411, upload-time = "2025-07-31T07:53:24.664Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pathspec"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.11.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
]
[[package]]
name = "pydantic-core"
version = "2.33.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
]
[[package]]
name = "python-dotenv"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.43"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d7/bc/d59b5d97d27229b0e009bd9098cd81af71c2fa5549c580a0a67b9bed0496/sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417", size = 9762949, upload-time = "2025-08-11T14:24:58.438Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/1c/a7260bd47a6fae7e03768bf66451437b36451143f36b285522b865987ced/sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3", size = 2130598, upload-time = "2025-08-11T15:51:15.903Z" },
{ url = "https://files.pythonhosted.org/packages/8e/84/8a337454e82388283830b3586ad7847aa9c76fdd4f1df09cdd1f94591873/sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa", size = 2118415, upload-time = "2025-08-11T15:51:17.256Z" },
{ url = "https://files.pythonhosted.org/packages/cf/ff/22ab2328148492c4d71899d62a0e65370ea66c877aea017a244a35733685/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9", size = 3248707, upload-time = "2025-08-11T15:52:38.444Z" },
{ url = "https://files.pythonhosted.org/packages/dc/29/11ae2c2b981de60187f7cbc84277d9d21f101093d1b2e945c63774477aba/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f", size = 3253602, upload-time = "2025-08-11T15:56:37.348Z" },
{ url = "https://files.pythonhosted.org/packages/b8/61/987b6c23b12c56d2be451bc70900f67dd7d989d52b1ee64f239cf19aec69/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738", size = 3183248, upload-time = "2025-08-11T15:52:39.865Z" },
{ url = "https://files.pythonhosted.org/packages/86/85/29d216002d4593c2ce1c0ec2cec46dda77bfbcd221e24caa6e85eff53d89/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164", size = 3219363, upload-time = "2025-08-11T15:56:39.11Z" },
{ url = "https://files.pythonhosted.org/packages/b6/e4/bd78b01919c524f190b4905d47e7630bf4130b9f48fd971ae1c6225b6f6a/sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d", size = 2096718, upload-time = "2025-08-11T15:55:05.349Z" },
{ url = "https://files.pythonhosted.org/packages/ac/a5/ca2f07a2a201f9497de1928f787926613db6307992fe5cda97624eb07c2f/sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197", size = 2123200, upload-time = "2025-08-11T15:55:07.932Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d9/13bdde6521f322861fab67473cec4b1cc8999f3871953531cf61945fad92/sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc", size = 1924759, upload-time = "2025-08-11T15:39:53.024Z" },
]
[[package]]
name = "starlette"
version = "0.47.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" },
]
[[package]]
name = "types-grpcio"
version = "1.0.0.20250703"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/59/17/c80b7231e337993b808aef32bcc556aad60810ca51eeb7acd1204eefe518/types_grpcio-1.0.0.20250703.tar.gz", hash = "sha256:baf100184e5353cb60f045fb4fd47f37a360bedf0f19581535e4c3a3a1f7912b", size = 14552, upload-time = "2025-07-03T03:14:01.923Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/e2/9c682ba59a7f9cf477bc7954c25ce1c20f881693651f354d15df577996c0/types_grpcio-1.0.0.20250703-py3-none-any.whl", hash = "sha256:78d1bfc33b58a56697ef99e666e34be4c6887631341c75fdd28d58587aef5d9f", size = 15274, upload-time = "2025-07-03T03:14:00.907Z" },
]
[[package]]
name = "types-protobuf"
version = "6.30.2.20250822"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/61/68/0c7144be5c6dc16538e79458839fc914ea494481c7e64566de4ecc0c3682/types_protobuf-6.30.2.20250822.tar.gz", hash = "sha256:faacbbe87bd8cba4472361c0bd86f49296bd36f7761e25d8ada4f64767c1bde9", size = 62379, upload-time = "2025-08-22T03:01:56.572Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/64/b926a6355993f712d7828772e42b9ae942f2d306d25072329805c374e729/types_protobuf-6.30.2.20250822-py3-none-any.whl", hash = "sha256:5584c39f7e36104b5f8bdfd31815fa1d5b7b3455a79ddddc097b62320f4b1841", size = 76523, upload-time = "2025-08-22T03:01:55.157Z" },
]
[[package]]
name = "typing-extensions"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
]
[[package]]
name = "uvicorn"
version = "0.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" },
{ url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" },
{ url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" },
{ url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" },
{ url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" },
{ url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406, upload-time = "2025-06-15T19:06:59.42Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/42/fae874df96595556a9089ade83be34a2e04f0f11eb53a8dbf8a8a5e562b4/watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30", size = 402004, upload-time = "2025-06-15T19:05:38.499Z" },
{ url = "https://files.pythonhosted.org/packages/fa/55/a77e533e59c3003d9803c09c44c3651224067cbe7fb5d574ddbaa31e11ca/watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a", size = 393671, upload-time = "2025-06-15T19:05:39.52Z" },
{ url = "https://files.pythonhosted.org/packages/05/68/b0afb3f79c8e832e6571022611adbdc36e35a44e14f129ba09709aa4bb7a/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc", size = 449772, upload-time = "2025-06-15T19:05:40.897Z" },
{ url = "https://files.pythonhosted.org/packages/ff/05/46dd1f6879bc40e1e74c6c39a1b9ab9e790bf1f5a2fe6c08b463d9a807f4/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b", size = 456789, upload-time = "2025-06-15T19:05:42.045Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ca/0eeb2c06227ca7f12e50a47a3679df0cd1ba487ea19cf844a905920f8e95/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895", size = 482551, upload-time = "2025-06-15T19:05:43.781Z" },
{ url = "https://files.pythonhosted.org/packages/31/47/2cecbd8694095647406645f822781008cc524320466ea393f55fe70eed3b/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a", size = 597420, upload-time = "2025-06-15T19:05:45.244Z" },
{ url = "https://files.pythonhosted.org/packages/d9/7e/82abc4240e0806846548559d70f0b1a6dfdca75c1b4f9fa62b504ae9b083/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b", size = 477950, upload-time = "2025-06-15T19:05:46.332Z" },
{ url = "https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c", size = 451706, upload-time = "2025-06-15T19:05:47.459Z" },
{ url = "https://files.pythonhosted.org/packages/81/b5/5516cf46b033192d544102ea07c65b6f770f10ed1d0a6d388f5d3874f6e4/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b", size = 625814, upload-time = "2025-06-15T19:05:48.654Z" },
{ url = "https://files.pythonhosted.org/packages/0c/dd/7c1331f902f30669ac3e754680b6edb9a0dd06dea5438e61128111fadd2c/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb", size = 622820, upload-time = "2025-06-15T19:05:50.088Z" },
{ url = "https://files.pythonhosted.org/packages/1b/14/36d7a8e27cd128d7b1009e7715a7c02f6c131be9d4ce1e5c3b73d0e342d8/watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9", size = 279194, upload-time = "2025-06-15T19:05:51.186Z" },
{ url = "https://files.pythonhosted.org/packages/25/41/2dd88054b849aa546dbeef5696019c58f8e0774f4d1c42123273304cdb2e/watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7", size = 292349, upload-time = "2025-06-15T19:05:52.201Z" },
{ url = "https://files.pythonhosted.org/packages/c8/cf/421d659de88285eb13941cf11a81f875c176f76a6d99342599be88e08d03/watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5", size = 283836, upload-time = "2025-06-15T19:05:53.265Z" },
{ url = "https://files.pythonhosted.org/packages/45/10/6faf6858d527e3599cc50ec9fcae73590fbddc1420bd4fdccfebffeedbc6/watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1", size = 400343, upload-time = "2025-06-15T19:05:54.252Z" },
{ url = "https://files.pythonhosted.org/packages/03/20/5cb7d3966f5e8c718006d0e97dfe379a82f16fecd3caa7810f634412047a/watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339", size = 392916, upload-time = "2025-06-15T19:05:55.264Z" },
{ url = "https://files.pythonhosted.org/packages/8c/07/d8f1176328fa9e9581b6f120b017e286d2a2d22ae3f554efd9515c8e1b49/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633", size = 449582, upload-time = "2025-06-15T19:05:56.317Z" },
{ url = "https://files.pythonhosted.org/packages/66/e8/80a14a453cf6038e81d072a86c05276692a1826471fef91df7537dba8b46/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011", size = 456752, upload-time = "2025-06-15T19:05:57.359Z" },
{ url = "https://files.pythonhosted.org/packages/5a/25/0853b3fe0e3c2f5af9ea60eb2e781eade939760239a72c2d38fc4cc335f6/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670", size = 481436, upload-time = "2025-06-15T19:05:58.447Z" },
{ url = "https://files.pythonhosted.org/packages/fe/9e/4af0056c258b861fbb29dcb36258de1e2b857be4a9509e6298abcf31e5c9/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf", size = 596016, upload-time = "2025-06-15T19:05:59.59Z" },
{ url = "https://files.pythonhosted.org/packages/c5/fa/95d604b58aa375e781daf350897aaaa089cff59d84147e9ccff2447c8294/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4", size = 476727, upload-time = "2025-06-15T19:06:01.086Z" },
{ url = "https://files.pythonhosted.org/packages/65/95/fe479b2664f19be4cf5ceeb21be05afd491d95f142e72d26a42f41b7c4f8/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20", size = 451864, upload-time = "2025-06-15T19:06:02.144Z" },
{ url = "https://files.pythonhosted.org/packages/d3/8a/3c4af14b93a15ce55901cd7a92e1a4701910f1768c78fb30f61d2b79785b/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef", size = 625626, upload-time = "2025-06-15T19:06:03.578Z" },
{ url = "https://files.pythonhosted.org/packages/da/f5/cf6aa047d4d9e128f4b7cde615236a915673775ef171ff85971d698f3c2c/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb", size = 622744, upload-time = "2025-06-15T19:06:05.066Z" },
{ url = "https://files.pythonhosted.org/packages/2c/00/70f75c47f05dea6fd30df90f047765f6fc2d6eb8b5a3921379b0b04defa2/watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297", size = 402114, upload-time = "2025-06-15T19:06:06.186Z" },
{ url = "https://files.pythonhosted.org/packages/53/03/acd69c48db4a1ed1de26b349d94077cca2238ff98fd64393f3e97484cae6/watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018", size = 393879, upload-time = "2025-06-15T19:06:07.369Z" },
{ url = "https://files.pythonhosted.org/packages/2f/c8/a9a2a6f9c8baa4eceae5887fecd421e1b7ce86802bcfc8b6a942e2add834/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0", size = 450026, upload-time = "2025-06-15T19:06:08.476Z" },
{ url = "https://files.pythonhosted.org/packages/fe/51/d572260d98388e6e2b967425c985e07d47ee6f62e6455cefb46a6e06eda5/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12", size = 457917, upload-time = "2025-06-15T19:06:09.988Z" },
{ url = "https://files.pythonhosted.org/packages/c6/2d/4258e52917bf9f12909b6ec314ff9636276f3542f9d3807d143f27309104/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb", size = 483602, upload-time = "2025-06-15T19:06:11.088Z" },
{ url = "https://files.pythonhosted.org/packages/84/99/bee17a5f341a4345fe7b7972a475809af9e528deba056f8963d61ea49f75/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77", size = 596758, upload-time = "2025-06-15T19:06:12.197Z" },
{ url = "https://files.pythonhosted.org/packages/40/76/e4bec1d59b25b89d2b0716b41b461ed655a9a53c60dc78ad5771fda5b3e6/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92", size = 477601, upload-time = "2025-06-15T19:06:13.391Z" },
{ url = "https://files.pythonhosted.org/packages/1f/fa/a514292956f4a9ce3c567ec0c13cce427c158e9f272062685a8a727d08fc/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e", size = 451936, upload-time = "2025-06-15T19:06:14.656Z" },
{ url = "https://files.pythonhosted.org/packages/32/5d/c3bf927ec3bbeb4566984eba8dd7a8eb69569400f5509904545576741f88/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b", size = 626243, upload-time = "2025-06-15T19:06:16.232Z" },
{ url = "https://files.pythonhosted.org/packages/e6/65/6e12c042f1a68c556802a84d54bb06d35577c81e29fba14019562479159c/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259", size = 623073, upload-time = "2025-06-15T19:06:17.457Z" },
{ url = "https://files.pythonhosted.org/packages/89/ab/7f79d9bf57329e7cbb0a6fd4c7bd7d0cee1e4a8ef0041459f5409da3506c/watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f", size = 400872, upload-time = "2025-06-15T19:06:18.57Z" },
{ url = "https://files.pythonhosted.org/packages/df/d5/3f7bf9912798e9e6c516094db6b8932df53b223660c781ee37607030b6d3/watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e", size = 392877, upload-time = "2025-06-15T19:06:19.55Z" },
{ url = "https://files.pythonhosted.org/packages/0d/c5/54ec7601a2798604e01c75294770dbee8150e81c6e471445d7601610b495/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa", size = 449645, upload-time = "2025-06-15T19:06:20.66Z" },
{ url = "https://files.pythonhosted.org/packages/0a/04/c2f44afc3b2fce21ca0b7802cbd37ed90a29874f96069ed30a36dfe57c2b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8", size = 457424, upload-time = "2025-06-15T19:06:21.712Z" },
{ url = "https://files.pythonhosted.org/packages/9f/b0/eec32cb6c14d248095261a04f290636da3df3119d4040ef91a4a50b29fa5/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f", size = 481584, upload-time = "2025-06-15T19:06:22.777Z" },
{ url = "https://files.pythonhosted.org/packages/d1/e2/ca4bb71c68a937d7145aa25709e4f5d68eb7698a25ce266e84b55d591bbd/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e", size = 596675, upload-time = "2025-06-15T19:06:24.226Z" },
{ url = "https://files.pythonhosted.org/packages/a1/dd/b0e4b7fb5acf783816bc950180a6cd7c6c1d2cf7e9372c0ea634e722712b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb", size = 477363, upload-time = "2025-06-15T19:06:25.42Z" },
{ url = "https://files.pythonhosted.org/packages/69/c4/088825b75489cb5b6a761a4542645718893d395d8c530b38734f19da44d2/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147", size = 452240, upload-time = "2025-06-15T19:06:26.552Z" },
{ url = "https://files.pythonhosted.org/packages/10/8c/22b074814970eeef43b7c44df98c3e9667c1f7bf5b83e0ff0201b0bd43f9/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8", size = 625607, upload-time = "2025-06-15T19:06:27.606Z" },
{ url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315, upload-time = "2025-06-15T19:06:29.076Z" },
]
[[package]]
name = "websockets"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]