Inject vibecode in my veins
This commit is contained in:
parent
6bc354d701
commit
fc192a4a0b
63 changed files with 54802 additions and 1277 deletions
|
|
@ -35,7 +35,6 @@ ENV/
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Project specific
|
# Project specific
|
||||||
uv.lock
|
|
||||||
.mypy_cache
|
.mypy_cache
|
||||||
.opencode
|
.opencode
|
||||||
|
|
||||||
|
|
|
||||||
45
CLAUDE.md
45
CLAUDE.md
|
|
@ -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
|
||||||
|
|
|
||||||
11
Dockerfile
11
Dockerfile
|
|
@ -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
149
alembic.ini
Normal 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
11
app/api/deps.py
Normal 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)
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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
64
app/cli.py
Normal 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()
|
||||||
|
|
@ -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}"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
weight: Mapped[float] = mapped_column(Float)
|
||||||
|
reps: Mapped[int]
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
async def create(self, approach: ApproachDTO) -> int:
|
exercise: Mapped["ExerciseModel"] = relationship(back_populates="approaches")
|
||||||
query = f"INSERT INTO {self.TABLE_NAME} (exercise_id, weight, reps) VALUES (?, ?, ?)"
|
|
||||||
return await self._executor.execute_mod_query(
|
|
||||||
query, (approach.exercise_id, approach.weight, approach.reps)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def list(self) -> List[Dict[str, Any]]:
|
|
||||||
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
|
|
||||||
|
|
|
||||||
5
app/core/database/models/base.py
Normal file
5
app/core/database/models/base.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
splitted_weight: Mapped[bool] = mapped_column(Boolean, server_default="false")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
async def create(self, exercise: ExerciseDTO) -> int:
|
training: Mapped["TrainingModel"] = relationship(back_populates="exercises")
|
||||||
query = f"INSERT INTO {self.TABLE_NAME} (training_id, name, splitted_weight) VALUES (?, ?, ?)"
|
approaches: Mapped[List["ApproachModel"]] = relationship(
|
||||||
return await self._executor.execute_mod_query(
|
back_populates="exercise",
|
||||||
query, (exercise.training_id, exercise.name, exercise.splitted_weight)
|
cascade="all, delete-orphan",
|
||||||
)
|
passive_deletes=True,
|
||||||
|
order_by="ApproachModel.id",
|
||||||
async def list(self) -> List[Dict[str, Any]]:
|
)
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
async def create(self, training: TrainingDTO) -> int:
|
exercises: Mapped[List["ExerciseModel"]] = relationship(
|
||||||
query = f"INSERT INTO {self.TABLE_NAME} (date, trainer) VALUES (?, ?)"
|
back_populates="training",
|
||||||
return await self._executor.execute_mod_query(
|
cascade="all, delete-orphan",
|
||||||
query, (training.date, training.trainer)
|
passive_deletes=True,
|
||||||
)
|
order_by="ExerciseModel.id",
|
||||||
|
)
|
||||||
async def list(self) -> List[Dict[str, Any]]:
|
|
||||||
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
|
|
||||||
|
|
|
||||||
114
app/core/database/repository.py
Normal file
114
app/core/database/repository.py
Normal 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
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
@ -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
120
app/core/metrics.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,7 @@ unique_apple_exercises_mapper = {
|
||||||
"Жим от груди сидя в тренажере (около трицепс машины)": "Жим от груди сидя в тренажере (рычаги)",
|
"Жим от груди сидя в тренажере (около трицепс машины)": "Жим от груди сидя в тренажере (рычаги)",
|
||||||
"Жим от груди сидя в тренажёре": "Жим от груди сидя в тренажере (рычаги)",
|
"Жим от груди сидя в тренажёре": "Жим от груди сидя в тренажере (рычаги)",
|
||||||
"Жим от груди сидя в тренажёре (блинчики)": "Жим от груди сидя в тренажере (рычаги)",
|
"Жим от груди сидя в тренажёре (блинчики)": "Жим от груди сидя в тренажере (рычаги)",
|
||||||
|
"Жим от груди сидя в тренажере (блинчики)": "Жим от груди сидя в тренажере (рычаги)",
|
||||||
"Силовой тренажер": "Жим от груди сидя в тренажере (рычаги)",
|
"Силовой тренажер": "Жим от груди сидя в тренажере (рычаги)",
|
||||||
|
|
||||||
# Жим от плеч вверх в тренажере (рычаги)
|
# Жим от плеч вверх в тренажере (рычаги)
|
||||||
|
|
@ -239,6 +240,7 @@ unique_apple_exercises_mapper = {
|
||||||
"Разгибание рук стоя с канатом": "Отжимание на трицепс в кроссовере",
|
"Разгибание рук стоя с канатом": "Отжимание на трицепс в кроссовере",
|
||||||
"Трацепс": "Отжимание на трицепс в кроссовере",
|
"Трацепс": "Отжимание на трицепс в кроссовере",
|
||||||
"Трицепс вниз у стойки": "Отжимание на трицепс в кроссовере",
|
"Трицепс вниз у стойки": "Отжимание на трицепс в кроссовере",
|
||||||
|
"Отжимания на трицепс в кроссовере": "Отжимание на трицепс в кроссовере",
|
||||||
|
|
||||||
# Подтягивания
|
# Подтягивания
|
||||||
"Подтягивание на заднюю дельту в петлях": "Подтягивания",
|
"Подтягивание на заднюю дельту в петлях": "Подтягивания",
|
||||||
|
|
@ -253,6 +255,7 @@ unique_apple_exercises_mapper = {
|
||||||
# Подъем гантелей на бицепс стоя
|
# Подъем гантелей на бицепс стоя
|
||||||
"Гантели": "Подъем гантелей на бицепс стоя",
|
"Гантели": "Подъем гантелей на бицепс стоя",
|
||||||
"Жим гантелей стоя": "Подъем гантелей на бицепс стоя",
|
"Жим гантелей стоя": "Подъем гантелей на бицепс стоя",
|
||||||
|
"Подъем гантелей стоя": "Подъем гантелей на бицепс стоя",
|
||||||
"Сгибание рук с гантелями стоя": "Подъем гантелей на бицепс стоя",
|
"Сгибание рук с гантелями стоя": "Подъем гантелей на бицепс стоя",
|
||||||
"Сгибание рук с гантелями стоя ям": "Подъем гантелей на бицепс стоя",
|
"Сгибание рук с гантелями стоя ям": "Подъем гантелей на бицепс стоя",
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
reps_list: List[int] = [int(rep) for rep in reps.split("-")]
|
try:
|
||||||
weight_splitted: bool = False
|
reps_list: List[int] = [int(rep) for rep in reps.split("-")]
|
||||||
weight_list: List[float] = []
|
# "15х2" (cyrillic х) and "15x2" mean the same: 15 kg per hand
|
||||||
|
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
|
for index, rep in enumerate(reps_list)
|
||||||
]
|
]
|
||||||
else:
|
return ExerciseDTO(
|
||||||
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:
|
|
||||||
weight_pointer += 1
|
|
||||||
approaches.append(approach)
|
|
||||||
|
|
||||||
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(
|
||||||
lines = training_data.splitlines()
|
date=parse_training_date(header.date), trainer=header.trainer
|
||||||
current_training = None
|
)
|
||||||
trains = []
|
except ValueError as e:
|
||||||
|
# Exercises below belong to an unknown training, skip them
|
||||||
|
current_training = None
|
||||||
|
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)
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,23 @@
|
||||||
import re
|
import re
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
|
DATE_PATTERNS = [
|
||||||
|
r"^(?P<day>[0-9]{2})\.(?P<month>[0-9]{2})\.(?P<year>[0-9]{4})",
|
||||||
|
r"^(?P<day>[0-9]{2})\.(?P<month>[0-9]{2})\.(?P<year>[0-9]{2})(?![0-9])",
|
||||||
|
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})",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def parse_training_date(raw_date: str) -> date:
|
def parse_training_date(raw_date: str) -> date:
|
||||||
patterns = [
|
for pattern in DATE_PATTERNS:
|
||||||
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<day>[0-9]{2})\.(?P<month>[0-9]{2})\.(?P<year>[0-9]{4})*",
|
|
||||||
]
|
|
||||||
|
|
||||||
for pattern in 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}")
|
||||||
|
|
|
||||||
|
|
@ -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 приложениях
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
5
app/static/css/dataTables.bootstrap5.min.css
vendored
Normal file
5
app/static/css/dataTables.bootstrap5.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||||
|
|
|
||||||
14
app/static/js/chart.umd.js
Normal file
14
app/static/js/chart.umd.js
Normal file
File diff suppressed because one or more lines are too long
4
app/static/js/dataTables.bootstrap5.min.js
vendored
Normal file
4
app/static/js/dataTables.bootstrap5.min.js
vendored
Normal 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
4
app/static/js/dataTables.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
247
app/static/js/i18n/datatables-ru.json
Normal file
247
app/static/js/i18n/datatables-ru.json
Normal 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": " "
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
});
|
||||||
});
|
});
|
||||||
54
app/templates/_trainings_table.html
Normal file
54
app/templates/_trainings_table.html
Normal 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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
162
app/templates/exercises.html
Normal file
162
app/templates/exercises.html
Normal 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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
|
||||||
<p>No exercises recorded</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>No training data available</p>
|
<p>Тренировки не найдены</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
11
compose.yaml
11
compose.yaml
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)**
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
1
migrations/README
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Generic single-database configuration with an async dbapi.
|
||||||
99
migrations/env.py
Normal file
99
migrations/env.py
Normal 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()
|
||||||
|
|
@ -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
28
migrations/script.py.mako
Normal 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"}
|
||||||
|
|
@ -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');
|
|
||||||
|
|
@ -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');
|
|
||||||
|
|
@ -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');
|
|
||||||
|
|
@ -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 ###
|
||||||
|
|
@ -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
7
requirements-dev.txt
Normal 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
10
requirements.txt
Normal 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
|
||||||
|
|
@ -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()
|
||||||
116
tests/test_core/test_metrics.py
Normal file
116
tests/test_core/test_metrics.py
Normal 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
|
||||||
124
tests/test_core/test_notes_parsing.py
Normal file
124
tests/test_core/test_notes_parsing.py
Normal 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")
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
150
tests/test_core/test_repository.py
Normal file
150
tests/test_core/test_repository.py
Normal 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
51701
trainings.json
Normal file
File diff suppressed because it is too large
Load diff
613
uv.lock
613
uv.lock
|
|
@ -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" },
|
|
||||||
]
|
|
||||||
Loading…
Reference in a new issue