Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""CLI: то же, что кнопка «Применить настройки Pi» в админке (install_fresh.sh вызывает этот скрипт)."""
from __future__ import annotations
import os
import sys
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
os.environ.setdefault("WESP_CONFIG", "production")
def main() -> int:
from app import create_app
from app.services.pi_platform_setup_service import apply_pi_platform_setup
from config import get_config_class
if len(sys.argv) > 1:
os.environ["WESP_BASE_DIR"] = os.path.abspath(sys.argv[1])
if len(sys.argv) > 2:
os.environ["WESP_VENV"] = os.path.abspath(sys.argv[2])
if len(sys.argv) > 3:
os.environ["WESP_SYSTEMD_UNIT"] = sys.argv[3].strip()
app = create_app(get_config_class(), run_migrations=False)
with app.app_context():
result = apply_pi_platform_setup(app.config)
print(result.get("message", result))
return 0 if result.get("ok") else 1
if __name__ == "__main__":
raise SystemExit(main())
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Аудит данных lab-модуля (профили, нормы, рационы)."""
from __future__ import annotations
import sys
from pathlib import Path
_ROOT = Path(__file__).resolve().parents[1]
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from app import create_app, db
from app.lab.commands.audit_lab_data import audit_lab_data, format_audit_report
from config import Config
def main() -> int:
include_components = "--components" in sys.argv
app = create_app(Config)
with app.app_context():
report = audit_lab_data(include_components=include_components)
print(format_audit_report(report))
db.session.remove()
return 0 if report.ok() else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
import ast
import io
import os
import re
import tokenize
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT.parent / "исходный_код_WESP.txt"
SKIP_DIRS = {
"__pycache__",
".git",
".pytest_cache",
".cursor",
"tests",
"migrations",
"vendor",
"logs",
"legacy",
}
SKIP_FILES = {
"proga.py",
}
EXTS = {".py", ".js", ".html", ".css"}
def should_skip_file(path: Path) -> bool:
if path.name in SKIP_FILES:
return True
if "legacy" in path.name.lower():
return True
return False
def should_skip_dir(name: str) -> bool:
return name in SKIP_DIRS
def iter_source_files():
for dirpath, dirnames, filenames in os.walk(ROOT):
dirnames[:] = [d for d in dirnames if not should_skip_dir(d)]
rel = Path(dirpath).relative_to(ROOT)
if any(part in SKIP_DIRS for part in rel.parts):
continue
for name in sorted(filenames):
path = Path(dirpath) / name
if path.suffix.lower() in EXTS and not should_skip_file(path):
yield path
def _drop_docstring(body):
if not body:
return body
first = body[0]
if not isinstance(first, ast.Expr):
return body
val = first.value
if isinstance(val, ast.Constant) and isinstance(val.value, str):
return body[1:]
return body
class _DocstringStripper(ast.NodeTransformer):
def visit_FunctionDef(self, node):
self.generic_visit(node)
node.body = _drop_docstring(node.body)
return node
def visit_AsyncFunctionDef(self, node):
self.generic_visit(node)
node.body = _drop_docstring(node.body)
return node
def visit_ClassDef(self, node):
self.generic_visit(node)
node.body = _drop_docstring(node.body)
return node
def visit_Module(self, node):
self.generic_visit(node)
node.body = _drop_docstring(node.body)
return node
def strip_python_comments(source: str) -> str:
out = []
try:
for tok in tokenize.generate_tokens(io.StringIO(source).readline):
if tok.type == tokenize.COMMENT:
continue
out.append(tok)
return tokenize.untokenize(out)
except (tokenize.TokenError, SyntaxError):
return source
def strip_python(source: str) -> str:
try:
tree = ast.parse(source)
tree = _DocstringStripper().visit(tree)
ast.fix_missing_locations(tree)
source = ast.unparse(tree)
except SyntaxError:
pass
return strip_python_comments(source)
def strip_js(source: str) -> str:
result = []
i = 0
n = len(source)
in_single = False
in_double = False
in_template = False
in_line_comment = False
in_block_comment = False
escape = False
while i < n:
ch = source[i]
nxt = source[i + 1] if i + 1 < n else ""
if in_line_comment:
if ch == "\n":
in_line_comment = False
result.append(ch)
i += 1
continue
if in_block_comment:
if ch == "*" and nxt == "/":
in_block_comment = False
i += 2
continue
i += 1
continue
if in_single:
result.append(ch)
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == "'":
in_single = False
i += 1
continue
if in_double:
result.append(ch)
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_double = False
i += 1
continue
if in_template:
result.append(ch)
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == "`":
in_template = False
i += 1
continue
if ch == "/" and nxt == "/":
in_line_comment = True
i += 2
continue
if ch == "/" and nxt == "*":
in_block_comment = True
i += 2
continue
if ch == "'":
in_single = True
result.append(ch)
i += 1
continue
if ch == '"':
in_double = True
result.append(ch)
i += 1
continue
if ch == "`":
in_template = True
result.append(ch)
i += 1
continue
result.append(ch)
i += 1
return "".join(result)
def strip_html_css(source: str) -> str:
source = re.sub(r"<!--[\s\S]*?-->", "", source)
source = strip_js(source)
return source
def collapse_blank_lines(text: str) -> str:
lines = [ln.rstrip() for ln in text.splitlines()]
cleaned = []
blank_run = 0
for ln in lines:
if not ln.strip():
blank_run += 1
if blank_run <= 2:
cleaned.append("")
continue
blank_run = 0
cleaned.append(ln)
return "\n".join(cleaned).strip("\n")
def strip_file(path: Path) -> str:
text = path.read_text(encoding="utf-8", errors="replace")
ext = path.suffix.lower()
if ext == ".py":
body = strip_python(text)
elif ext == ".js":
body = strip_js(text)
else:
body = strip_html_css(text)
return collapse_blank_lines(body)
def main():
chunks = []
for path in iter_source_files():
body = strip_file(path)
if body:
chunks.append(body)
content = "\n\n".join(chunks)
OUT.write_text(content + "\n", encoding="utf-8")
lines = content.count("\n") + (1 if content else 0)
print(f"Written: {OUT}")
print(f"Files: {len(chunks)}")
print(f"Lines: {lines}")
print(f"Size: {OUT.stat().st_size} bytes")
if __name__ == "__main__":
main()
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env bash
# Сборка OTA-релиза WESP: код + полный vendor/wheels (offline-установка на клиенте).
#
# Каждый ZIP — самодостаточный пакет: pip download → verify → zip.
# x86 и ARM — разные релизы: собирайте на машине той же ОС/arch, что терминалы
# в проде, и публикуйте в отдельный Gitea-репозиторий.
#
# Полный цикл (пути с пробелами — в кавычках):
# bash "./scripts/build_release.sh" 2.0.5
# → dist/wesp-2.0.5-linux-x86_64.zip (или darwin-x86_64, linux-aarch64 …)
# Собирайте на той же ОС/Python, что на терминалах в проде (см. WESP_PREPARE_PYTHON).
#
# Только wheelhouse (без ZIP):
# ./scripts/build_release.sh --wheelhouse-only
#
# ZIP без повторного pip download (wheels уже собраны на этой же машине):
# ./scripts/build_release.sh --skip-prepare 2.0.5
set -euo pipefail
SKIP_PREPARE=0
WHEELHOUSE_ONLY=0
VERSION=""
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-prepare) SKIP_PREPARE=1; shift ;;
--wheelhouse-only) WHEELHOUSE_ONLY=1; shift ;;
-h|--help)
sed -n '2,16p' "$0"
exit 0
;;
-*) echo "Unknown option: $1" >&2; exit 1 ;;
*)
if [[ -z "$VERSION" ]]; then
VERSION="$1"
else
echo "Unexpected argument: $1" >&2
exit 1
fi
shift
;;
esac
done
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PYTHON="${WESP_PREPARE_PYTHON:-python3}"
REQ="${ROOT}/requirements-prod.txt"
WHEELS="${ROOT}/vendor/wheels"
BUILD_ARCH="$(uname -m 2>/dev/null || echo unknown)"
BUILD_PLATFORM="$("$PYTHON" -c 'import platform; print(platform.system())' 2>/dev/null || uname -s 2>/dev/null || echo unknown)"
BUILD_OS="$(echo "$BUILD_PLATFORM" | tr '[:upper:]' '[:lower:]')"
BUILD_TAG="${BUILD_OS}-${BUILD_ARCH}"
log_build_target() {
echo "==> Build target: ${BUILD_PLATFORM} / ${BUILD_ARCH} (${BUILD_TAG})"
echo " ZIP содержит vendor/wheels для этой ОС. Другая arch → другой репозиторий."
}
wheelhouse_file_count() {
find "$WHEELS" -type f \( -name '*.whl' -o -name '*.tar.gz' -o -name '*.zip' \) 2>/dev/null | wc -l | tr -d ' '
}
clean_wheelhouse() {
echo "==> Clean vendor/wheels (кроме .gitkeep)"
mkdir -p "$WHEELS"
find "$WHEELS" -mindepth 1 ! -name '.gitkeep' -delete
}
prepare_wheelhouse() {
log_build_target
clean_wheelhouse
echo "==> Download wheels (PyPI) → vendor/wheels/ [requirements-prod.txt + транзитивные deps]"
"$PYTHON" -m pip download --prefer-binary -r "$REQ" -d "$WHEELS"
"$PYTHON" -m pip download setuptools wheel packaging -d "$WHEELS"
echo "==> Materialize sdists → wheels (zeroconf и др., без Cython с PyPI на терминале)"
"$PYTHON" "${ROOT}/scripts/materialize_wheelhouse_sdists.py" \
--wheels "$WHEELS" \
--python "$PYTHON"
"$PYTHON" "${ROOT}/scripts/verify_release_deps.py" \
--req "$REQ" \
--python "$PYTHON" \
--verify-closure \
--write-manifest \
--build-arch "$BUILD_ARCH" \
--build-platform "$BUILD_PLATFORM"
}
verify_wheelhouse() {
echo "==> Verify vendor/wheels (обязательно перед упаковкой)"
"$PYTHON" "${ROOT}/scripts/verify_release_deps.py" \
--req "$REQ" \
--python "$PYTHON" \
--verify-closure
}
verify_static_assets() {
echo "==> Verify static UI assets (offline, no CDN in HTML/CSS)"
"$PYTHON" "${ROOT}/scripts/verify_static_offline_assets.py"
}
zip_wheelhouse_summary() {
local archive="$1"
local whl_in_zip whl_bytes_in_zip
whl_in_zip="$(unzip -l "$archive" 2>/dev/null | grep -c 'vendor/wheels/.*\.\(whl\|tar\.gz\)$' || echo 0)"
whl_bytes_in_zip="$(unzip -l "$archive" 2>/dev/null | grep 'vendor/wheels/.*\.\(whl\|tar\.gz\)$' | awk '{s+=$1} END {print s+0}')"
echo " vendor/wheels в ZIP: ${whl_in_zip} файлов, ~$(( whl_bytes_in_zip / 1024 / 1024 )) MB (несжатые .whl)"
}
build_zip() {
if [[ -z "$VERSION" ]]; then
echo "Usage: $0 <version>" >&2
echo "Example: $0 2.0.5" >&2
exit 1
fi
local out_dir="${ROOT}/dist"
local archive="${out_dir}/wesp-${VERSION}-${BUILD_TAG}.zip"
local staging="${out_dir}/.staging-${VERSION}-${BUILD_TAG}"
local whl_count whl_disk_mb
whl_count="$(wheelhouse_file_count)"
whl_disk_mb="$(du -sk "$WHEELS" 2>/dev/null | awk '{print int($1/1024)}')"
if [[ "$whl_count" -lt 1 ]]; then
echo "ERROR: vendor/wheels пуст — нельзя собрать OTA без зависимостей" >&2
exit 1
fi
echo "==> Pack $(basename "$archive") (wheelhouse на диске: ${whl_count} pkg, ~${whl_disk_mb} MB)"
rm -rf "$staging"
mkdir -p "$staging" "$out_dir"
rsync -a \
--exclude='.git/' \
--exclude='.venv/' \
--exclude='wes_mac/' \
--exclude='test/' \
--exclude='__pycache__/' \
--exclude='.pytest_cache/' \
--exclude='*.pyc' \
--exclude='.secret/' \
--exclude='backups/' \
--exclude='temp_updates/' \
--exclude='dist/' \
--exclude='data/*.db' \
--exclude='data/*.db-shm' \
--exclude='data/*.db-wal' \
--exclude='data/logs/' \
--exclude='data/update_state.json' \
--exclude='data/.pending_restart.json' \
--exclude='data/wesp_install_state.json' \
--exclude='data/wesp_security_settings.json' \
--exclude='data/wesp_network_settings.json' \
--exclude='data/wesp_network_diagnostics.json' \
--exclude='data/sync_client_state.json' \
--exclude='.DS_Store' \
--exclude='.cursor/' \
"$ROOT/" "$staging/wesp/"
if [[ ! -d "$staging/wesp/vendor/wheels" ]] || [[ -z "$(find "$staging/wesp/vendor/wheels" -type f -name '*.whl' 2>/dev/null | head -1)" ]]; then
echo "ERROR: vendor/wheels не попал в staging — прервано" >&2
exit 1
fi
mkdir -p "$staging/wesp/data/logs"
touch "$staging/wesp/data/logs/.gitkeep"
echo "{\"version\": \"${VERSION}\"}" > "$staging/wesp/data/config.json"
rm -f "$archive"
(cd "$staging" && zip -rq "$archive" wesp)
rm -rf "$staging"
local size files
size="$(du -sh "$archive" | awk '{print $1}')"
files="$(unzip -l "$archive" | tail -1 | awk '{print $2}')"
echo ""
echo "════════════════════════════════════════"
echo "OTA package: $(basename "$archive")"
echo " Архив: ${size}, ${files} files"
zip_wheelhouse_summary "$archive"
echo " Платформа: ${BUILD_TAG} → свой Gitea-реpo для этих терминалов"
echo " Tag: v${VERSION} or ${VERSION}"
echo "════════════════════════════════════════"
}
if [[ "$SKIP_PREPARE" -eq 0 ]]; then
prepare_wheelhouse
else
echo "==> Skip pip download (--skip-prepare; wheels должны быть с этой же машины/arch)"
if [[ "$(wheelhouse_file_count)" -lt 1 ]]; then
echo "ERROR: vendor/wheels пуст — уберите --skip-prepare" >&2
exit 1
fi
fi
if [[ "$WHEELHOUSE_ONLY" -eq 1 ]]; then
echo "Done (wheelhouse only)."
exit 0
fi
verify_wheelhouse
verify_static_assets
build_zip
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Deprecated: use ./scripts/build_release.sh
exec "$(cd "$(dirname "$0")" && pwd)/build_release.sh" "$@"
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Удаление legacy и fp_* профилей стада."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from app import create_app # noqa: E402
from app.lab.commands.cleanup_legacy_profiles import cleanup_legacy_profiles # noqa: E402
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--apply", action="store_true", help="Execute deletion (default: dry-run)")
args = parser.parse_args()
app = create_app()
with app.app_context():
report = cleanup_legacy_profiles(dry_run=not args.apply)
print(f"dry_run={report.dry_run}")
print(f"profiles_to_delete ({len(report.profiles_to_delete)}):")
for key in report.profiles_to_delete:
print(f" - {key}")
print(f"recipe_ids_cleared ({len(report.recipe_ids_cleared)}):")
for rid in report.recipe_ids_cleared:
print(f" - {rid}")
print(f"norms_deleted={report.norms_deleted} profiles_deleted={report.profiles_deleted}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Подсчёт строк кода WESP (см. docs/README.full.md)."""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
EXCLUDE_DIRS = {
".venv",
"vendor",
"data",
".git",
"legacy",
"__pycache__",
"node_modules",
".pytest_cache",
"htmlcov",
"dist",
"backups",
".cursor",
"test", # Windows venv test/Lib/site-packages
}
EXTS = {
".py": "Python",
".js": "JavaScript",
".css": "CSS",
".html": "HTML",
".sh": "Shell",
}
def should_skip(path: Path) -> bool:
try:
rel = path.relative_to(ROOT)
except ValueError:
return True
return any(part in EXCLUDE_DIRS for part in rel.parts)
def count_file(path: Path) -> tuple[int, int]:
text = path.read_text(encoding="utf-8", errors="ignore")
code = blank = 0
for line in text.splitlines():
if line.strip():
code += 1
else:
blank += 1
return code, blank
def main() -> None:
stats: dict[str, dict[str, int]] = {
ext: {"files": 0, "code": 0, "blank": 0} for ext in EXTS
}
for path in sorted(ROOT.rglob("*")):
if not path.is_file() or should_skip(path):
continue
ext = path.suffix.lower()
if ext not in EXTS:
continue
code, blank = count_file(path)
stats[ext]["files"] += 1
stats[ext]["code"] += code
stats[ext]["blank"] += blank
print(f"WESP LOC — {ROOT}")
print(f"{'Language':<12} {'files':>6} {'code':>8} {'blank':>8}")
print("-" * 38)
total = {"files": 0, "code": 0, "blank": 0}
for ext in sorted(EXTS, key=lambda e: -stats[e]["code"]):
s = stats[ext]
if not s["files"]:
continue
print(
f"{EXTS[ext]:<12} {s['files']:>6} {s['code']:>8} {s['blank']:>8}"
)
for k in total:
total[k] += s[k]
print("-" * 38)
print(f"{'SUM':<12} {total['files']:>6} {total['code']:>8} {total['blank']:>8}")
py_by_dir: dict[str, int] = {}
for path in sorted(ROOT.rglob("*.py")):
if should_skip(path):
continue
rel = path.relative_to(ROOT)
top = rel.parts[0] if len(rel.parts) > 1 else "."
code, _ = count_file(path)
py_by_dir[top] = py_by_dir.get(top, 0) + code
print("\nPython by top-level dir:")
for name, lines in sorted(py_by_dir.items(), key=lambda x: -x[1]):
print(f" {name:20} {lines:>6}")
if __name__ == "__main__":
main()
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Generate WESP favicons: Cyrillic «К» on transparent background."""
from __future__ import annotations
import sys
from pathlib import Path
from fontTools.ttLib import TTFont
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
STATIC = ROOT / "static"
CYRILLIC_K = "\u041a"
BRAND = (255, 255, 255, 255)
FONT_CANDIDATES = [
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/Library/Fonts/Arial Unicode.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
]
def _pick_font_path() -> Path:
for candidate in FONT_CANDIDATES:
path = Path(candidate)
if path.is_file():
return path
raise FileNotFoundError("No suitable bold font found for Cyrillic «К»")
def _write_svg(dest: Path) -> None:
svg = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Комптон">
<text x="16" y="24.5" text-anchor="middle"
font-family="Arial, Helvetica, 'DejaVu Sans', sans-serif"
font-size="22" font-weight="700" fill="#ffffff">К</text>
</svg>
"""
dest.write_text(svg, encoding="utf-8")
def _draw_png(size: int, font_path: Path, dest: Path) -> None:
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
font = ImageFont.truetype(str(font_path), round(size * 0.72))
bbox = draw.textbbox((0, 0), CYRILLIC_K, font=font)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
x = (size - tw) / 2 - bbox[0]
y = (size - th) / 2 - bbox[1] - size * 0.02
draw.text((x, y), CYRILLIC_K, font=font, fill=BRAND)
img.save(dest, format="PNG")
def main() -> int:
font_path = _pick_font_path()
_write_svg(STATIC / "favicon.svg")
_draw_png(32, font_path, STATIC / "favicon-32.png")
_draw_png(180, font_path, STATIC / "apple-touch-icon.png")
print(f"Generated favicons using {font_path}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Импорт справочников RACION (JSON seed) в БД.
Требует миграцию lab_racion_normy (0023). Скрипт применяет alembic upgrade head синхронно.
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
def main() -> int:
parser = argparse.ArgumentParser(description="Import RACION NORMY reference into DB")
parser.add_argument("--no-replace", action="store_true", help="Do not clear tables before import")
parser.add_argument("--sync-profile", metavar="KEY", help="Sync racion norms to profile by profile_key")
args = parser.parse_args()
# Без фонового старта — иначе create_app() откладывает миграции и таблиц ещё нет.
os.environ.setdefault("WESP_BACKGROUND_STARTUP", "0")
from app import create_app
app = create_app(run_migrations=True)
with app.app_context():
from app.lab.services.racion_reference import import_racion_reference
stats = import_racion_reference(replace=not args.no_replace)
if stats.errors:
for err in stats.errors:
print(f"ERROR: {err}", file=sys.stderr)
return 1
print(
f"Imported: moskwa={stats.moskwa_rows} piter={stats.piter_rows} info={stats.info_rows}"
)
if args.sync_profile:
from app.lab.models import LabAnimalProfile
from app.lab.services.profile_norms import sync_racion_norms_to_profile
profile = LabAnimalProfile.query.filter_by(
profile_key=args.sync_profile, is_deleted=False
).first()
if profile is None:
print(f"Profile not found: {args.sync_profile}", file=sys.stderr)
return 1
n = sync_racion_norms_to_profile(profile)
print(f"Synced {n} norm rows for profile {args.sync_profile}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Импорт seed-данных WESP: нормы профилей и нутриенты компонентов."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from app import create_app # noqa: E402
from app.lab.commands.import_seed import import_component_nutrients, import_norm_profiles # noqa: E402
def main() -> int:
parser = argparse.ArgumentParser(description="Import WESP seed CSV from data/seed/")
parser.add_argument("--norms", action="store_true", help="Import norm profiles")
parser.add_argument("--nutrients", action="store_true", help="Import component nutrients CSV")
parser.add_argument("--dry-run", action="store_true", help="Nutrients import dry-run only")
args = parser.parse_args()
if not args.norms and not args.nutrients:
parser.error("Specify --norms and/or --nutrients")
app = create_app()
with app.app_context():
if args.norms:
stats = import_norm_profiles()
print(
f"norms: created={stats.profiles_created} updated={stats.profiles_updated} "
f"norm_rows={stats.norm_rows}"
)
if args.nutrients:
stats = import_component_nutrients(dry_run=args.dry_run)
print(
f"nutrients: imported={stats.imported} skipped={stats.skipped} "
f"unmatched={stats.unmatched}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Offline import: tab Docker PostgreSQL → WESP SQLite.
Usage:
TAB_REFERENCE_DATABASE_URL=postgresql://neoton:neoton_secret@localhost:5432/neoton \\
python3 scripts/import_tab_reference_db.py
Requires: alembic upgrade head (migration lab_module) applied first.
"""
from __future__ import annotations
import os
import sys
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from app import create_app
from config import DevelopmentConfig
from app.lab.etl.reference_db_import import DEFAULT_PG_URL, import_from_reference_db
def main() -> int:
pg_url = os.environ.get("TAB_REFERENCE_DATABASE_URL", DEFAULT_PG_URL)
print(f"Source: {pg_url}")
os.environ.setdefault("WESP_BACKGROUND_STARTUP", "0")
app = create_app(DevelopmentConfig, run_migrations=True)
with app.app_context():
stats = import_from_reference_db(pg_url)
print("Import complete:")
for key, value in stats.__dict__.items():
if key == "errors":
continue
print(f" {key}: {value}")
if stats.errors:
for err in stats.errors:
print(f" error: {err}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Чистая установка WESP на Linux-сервер (код + venv + первый старт с миграциями).
#
# Пример (код в /opt/wesp, venv в /opt/wes, unit myscript):
# sudo bash scripts/install_fresh.sh /opt/wesp /opt/wes myscript
#
# После установки отредактируйте /etc/wesp/wesp.env (секреты) при необходимости.
# systemd unit и переменные restart/reboot прописываются автоматически.
#
set -euo pipefail
WESP_HOME="${1:-/opt/wesp}"
WESP_VENV="${2:-/opt/wes}"
WESP_SYSTEMD_UNIT="${3:-myscript}"
PYTHON="${WESP_PYTHON:-python3}"
ENV_FILE="${WESP_ENV_FILE:-/etc/wesp/wesp.env}"
log() { echo "==> $*"; }
if [[ ! -f "${WESP_HOME}/run.py" ]]; then
echo "ERROR: не найден ${WESP_HOME}/run.py — укажите каталог с кодом WESP" >&2
exit 1
fi
cd "$WESP_HOME"
if [[ ! -d "$WESP_VENV/bin" ]]; then
log "Создаём venv: $WESP_VENV"
"$PYTHON" -m venv "$WESP_VENV"
fi
PY="${WESP_VENV}/bin/python"
PIP="${WESP_VENV}/bin/pip"
log "Установка зависимостей"
if [[ -d "${WESP_HOME}/vendor/wheels" ]] && compgen -G "${WESP_HOME}/vendor/wheels/*.whl" > /dev/null; then
WESP_VENV="$WESP_VENV" WESP_PYTHON="$PY" "$PY" "${WESP_HOME}/install_deps.py"
else
log "vendor/wheels пуст — pip install -r requirements-prod.txt (нужен PyPI)"
"$PIP" install -U pip wheel
"$PIP" install -r "${WESP_HOME}/requirements-prod.txt"
fi
log "Каталоги data/"
mkdir -p "${WESP_HOME}/data/logs"
touch "${WESP_HOME}/data/logs/.gitkeep"
log "Настройка Pi (wesp.env + systemd) — как в админке"
export WESP_VENV="$WESP_VENV" WESP_SYSTEMD_UNIT="$WESP_SYSTEMD_UNIT"
if ! "$PY" "${WESP_HOME}/scripts/apply_pi_platform_setup.py" "$WESP_HOME" "$WESP_VENV" "$WESP_SYSTEMD_UNIT"; then
echo "WARN: apply_pi_platform_setup завершился с ошибкой (нужен root?)" >&2
fi
log "Пробный старт (миграции + bootstrap)"
set -a
# shellcheck disable=SC1090
[[ -f "$ENV_FILE" ]] && source "$ENV_FILE"
set +a
export WESP_CONFIG="${WESP_CONFIG:-production}"
export WESP_ALLOW_INSECURE_DEFAULTS="${WESP_ALLOW_INSECURE_DEFAULTS:-1}"
"$PY" - <<'PY'
from app import create_app
from config import get_config_class
app = create_app(get_config_class(), run_migrations=True)
print("OK: create_app + migrations + bootstrap")
PY
log "Готово."
echo ""
echo " Код: $WESP_HOME"
echo " venv: $WESP_VENV"
echo " env: $ENV_FILE"
echo " unit: ${WESP_SYSTEMD_UNIT}.service"
echo ""
echo " Запуск: sudo systemctl restart ${WESP_SYSTEMD_UNIT}"
echo " Статус: sudo systemctl status ${WESP_SYSTEMD_UNIT}"
echo " Логи: journalctl -u ${WESP_SYSTEMD_UNIT} -f"
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""
Собрать sdist (.tar.gz) из vendor/wheels в готовые .whl на build-машине.
pip download иногда кладёт zeroconf как исходники (нет готового wheel под Python/arch).
Офлайн pip install на терминале не должен тянуть Cython с PyPI — wheel собирается здесь.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
# PEP 517 build deps для zeroconf и похожих sdists в requirements-prod.
SDIST_BUILD_DEPS = (
"Cython>=3.0.8",
"setuptools>=61",
"wheel",
"packaging",
)
def materialize_sdists(wheels_dir: Path, python_exe: str) -> list[str]:
"""Собрать каждый .tar.gz в wheels_dir → .whl, удалить sdist. Возвращает имена .whl."""
sdists = sorted(wheels_dir.glob("*.tar.gz"))
if not sdists:
return []
subprocess.run(
[
python_exe,
"-m",
"pip",
"download",
*SDIST_BUILD_DEPS,
"-d",
str(wheels_dir),
],
check=True,
)
built: list[str] = []
for sdist in sdists:
before = {p.name for p in wheels_dir.glob("*.whl")}
subprocess.run(
[
python_exe,
"-m",
"pip",
"wheel",
str(sdist.resolve()),
"-w",
str(wheels_dir),
"--no-deps",
f"--find-links={wheels_dir}",
"--no-cache-dir",
],
check=True,
)
after = {p.name for p in wheels_dir.glob("*.whl")}
new_wheels = sorted(after - before)
if not new_wheels:
raise RuntimeError(
f"pip wheel не создал .whl для {sdist.name} в {wheels_dir}"
)
built.extend(new_wheels)
sdist.unlink()
return built
def main() -> int:
parser = argparse.ArgumentParser(description="Build wheels from vendor/wheels sdists")
parser.add_argument("--wheels", type=Path, default=ROOT / "vendor" / "wheels")
parser.add_argument("--python", default=sys.executable)
args = parser.parse_args()
wheels_dir = args.wheels.resolve()
if not wheels_dir.is_dir():
print(f"materialize_wheelhouse_sdists: нет каталога {wheels_dir}", file=sys.stderr)
return 1
try:
built = materialize_sdists(wheels_dir, args.python)
except subprocess.CalledProcessError as exc:
print("materialize_wheelhouse_sdists: ошибка сборки wheel", file=sys.stderr)
return exc.returncode or 1
if built:
print(
"materialize_wheelhouse_sdists: собрано wheel из sdist:",
", ".join(built),
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,26 @@
# Радужный splash прошивки Pi
Большой цветной квадрат **до** Plymouth отключается в `config.txt`:
```ini
disable_splash=1
```
## Из админки WESP
**Система → Радужный экран Pi (firmware) → «Отключить радугу»**
Нужен `sudo` для записи в `/boot/firmware/config.txt`.
## Вручную
```bash
sudo nano /boot/firmware/config.txt
# в [all]:
disable_splash=1
sudo reboot
```
## Sudoers (опционально)
Пользователь службы WESP должен иметь право `sudo cp` в config.txt без пароля.
+38
View File
@@ -0,0 +1,38 @@
# Plymouth WESP (800×600)
Тема загрузки с той же пиксельной анимацией логотипа, что на `/starting`.
## Зависимости на устройстве
```bash
sudo apt install plymouth plymouth-themes python3-pil
```
## Из админки
**Система → Заставка загрузки (Plymouth) → «Установить тему Plymouth»** — рендер кадров, копирование темы, `sudo install_wesp_theme.sh`, `update-initramfs -u`.
Нужен **sudo без пароля** для скрипта установки или переменная:
```bash
export WESP_PLYMOUTH_INSTALL_CMD='/usr/bin/sudo /path/to/wesp/scripts/plymouth/install_wesp_theme.sh'
```
## Вручную
```bash
cd /opt/wesp # каталог с wesp/
python3 scripts/plymouth/build_frames.py --out data/plymouth-build
cp install/plymouth/wesp/wesp.plymouth install/plymouth/wesp/wesp.script data/plymouth-build/
sudo bash scripts/plymouth/install_wesp_theme.sh data/plymouth-build
sudo reboot
```
## Файлы
| Путь | Назначение |
|------|------------|
| `install/plymouth/wesp/wesp.script` | Plymouth Script (цикл кадров) |
| `install/plymouth/wesp/wesp.plymouth` | Манифест темы |
| `app/services/plymouth_frame_renderer.py` | Рендер PNG (Pillow) |
| `data/plymouth-build/animation/*.png` | Сгенерированные кадры (~165 шт., 25 fps, 6 с) |
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""CLI: сгенерировать кадры Plymouth (800×600) из logo2.png."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.services.plymouth_frame_renderer import ( # noqa: E402
DURATION_MS_DEFAULT,
PLYMOUTH_FPS,
PLYMOUTH_HEIGHT,
PLYMOUTH_WIDTH,
render_all_frames,
)
def main() -> int:
parser = argparse.ArgumentParser(description="Render WESP Plymouth animation frames")
parser.add_argument(
"--logo",
type=Path,
default=ROOT / "static" / "logo2.png",
help="Path to logo2.png",
)
parser.add_argument(
"--out",
type=Path,
default=ROOT / "data" / "plymouth-build",
help="Output directory (animation/ inside)",
)
parser.add_argument("--width", type=int, default=PLYMOUTH_WIDTH)
parser.add_argument("--height", type=int, default=PLYMOUTH_HEIGHT)
parser.add_argument("--duration-ms", type=int, default=DURATION_MS_DEFAULT)
parser.add_argument("--fps", type=int, default=PLYMOUTH_FPS)
args = parser.parse_args()
if not args.logo.is_file():
print(f"Logo not found: {args.logo}", file=sys.stderr)
return 1
def progress(done: int, total: int) -> None:
pct = int(100 * done / total) if total else 100
print(f"\rFrames {done}/{total} ({pct}%)", end="", flush=True)
summary = render_all_frames(
args.logo,
args.out,
width=args.width,
height=args.height,
duration_ms=args.duration_ms,
fps=args.fps,
progress_callback=progress,
)
print()
print(summary)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Установка темы Plymouth WESP (нужен root).
# Использование: install_wesp_theme.sh /path/to/built/theme
set -euo pipefail
SRC="${1:-}"
if [[ -z "${SRC}" || ! -d "${SRC}" ]]; then
echo "Usage: $0 /path/to/theme-dir" >&2
exit 2
fi
if [[ "$(id -u)" -ne 0 ]]; then
echo "Запустите от root (sudo)." >&2
exit 1
fi
THEME_ID="wesp"
DEST="/usr/share/plymouth/themes/${THEME_ID}"
PLYMOUTH_FILE="${DEST}/${THEME_ID}.plymouth"
if ! command -v plymouth >/dev/null 2>&1; then
echo "plymouth не найден. Установите: apt install plymouth plymouth-themes" >&2
exit 1
fi
if [[ ! -f "${SRC}/${THEME_ID}.plymouth" || ! -f "${SRC}/${THEME_ID}.script" ]]; then
echo "В ${SRC} нет ${THEME_ID}.plymouth / ${THEME_ID}.script" >&2
exit 1
fi
if [[ ! -d "${SRC}/animation" ]] || [[ -z "$(ls -A "${SRC}/animation" 2>/dev/null || true)" ]]; then
echo "Нет кадров в ${SRC}/animation — сначала сгенерируйте анимацию." >&2
exit 1
fi
install -d "${DEST}"
rsync -a --delete "${SRC}/" "${DEST}/"
if command -v update-alternatives >/dev/null 2>&1; then
update-alternatives --install \
/usr/share/plymouth/themes/default.plymouth default.plymouth \
"${PLYMOUTH_FILE}" 200
update-alternatives --set default.plymouth "${PLYMOUTH_FILE}"
elif command -v plymouth-set-default-theme >/dev/null 2>&1; then
plymouth-set-default-theme -R "${THEME_ID}"
else
ln -sf "${PLYMOUTH_FILE}" /etc/alternatives/default.plymouth 2>/dev/null || true
fi
if command -v plymouth-set-default-theme >/dev/null 2>&1; then
plymouth-set-default-theme "${THEME_ID}" 2>/dev/null || true
fi
if command -v update-initramfs >/dev/null 2>&1; then
echo "==> update-initramfs -u"
update-initramfs -u
else
echo "update-initramfs не найден — пересоберите initramfs вручную." >&2
fi
echo "Тема WESP установлена: ${DEST}"
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Post-update: restart service, health-check, rollback on failure.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
PENDING="${ROOT}/data/.pending_restart.json"
LISTEN="${WESP_LISTEN:-0.0.0.0:80}"
HEALTH_URL="${WESP_HEALTH_URL:-http://127.0.0.1/api/health}"
HEALTH_TIMEOUT="${WESP_HEALTH_TIMEOUT_SEC:-60}"
HEALTH_INTERVAL="${WESP_HEALTH_INTERVAL_SEC:-2}"
wesp_python() {
local venv_dir py
if [[ -n "${WESP_PYTHON:-}" && ( -x "${WESP_PYTHON}" || -f "${WESP_PYTHON}" ) ]]; then
echo "${WESP_PYTHON}"
return
fi
venv_dir="${WESP_VENV:-/opt/wes}"
for py in python python3 python3.13 python3.12 python3.11; do
if [[ -x "${venv_dir}/bin/${py}" ]]; then
echo "${venv_dir}/bin/${py}"
return
fi
done
if [[ -x "${ROOT}/../wes_mac/bin/python" ]]; then
echo "${ROOT}/../wes_mac/bin/python"
return
fi
if [[ -x "${ROOT}/.venv/bin/python" ]]; then
echo "${ROOT}/.venv/bin/python"
return
fi
command -v python3
}
wesp_waitress() {
local venv_dir
venv_dir="${WESP_VENV:-/opt/wes}"
if [[ -x "${venv_dir}/bin/waitress-serve" ]]; then
echo "${venv_dir}/bin/waitress-serve"
return
fi
local py
py="$(wesp_python)"
if [[ -x "${py%/*}/waitress-serve" ]]; then
echo "${py%/*}/waitress-serve"
return
fi
if [[ -x "${ROOT}/../wes_mac/bin/waitress-serve" ]]; then
echo "${ROOT}/../wes_mac/bin/waitress-serve"
return
fi
if [[ -x "${ROOT}/.venv/bin/waitress-serve" ]]; then
echo "${ROOT}/.venv/bin/waitress-serve"
return
fi
command -v waitress-serve
}
stop_service() {
pkill -f "waitress-serve.*wsgi:app" 2>/dev/null || true
sleep 2
}
start_service() {
if [[ "${WESP_SKIP_RESTART:-}" == "1" ]]; then
return 0
fi
LOG="${ROOT}/data/logs/wesp-stdout.log"
mkdir -p "${ROOT}/data/logs"
export WESP_LISTEN="$LISTEN"
nohup bash "${ROOT}/scripts/wesp_waitress_serve.sh" >>"$LOG" 2>&1 &
disown || true
}
health_ok() {
if [[ -n "${WESP_HEALTH_CMD:-}" ]]; then
bash -c "$WESP_HEALTH_CMD"
return $?
fi
curl -sf "$HEALTH_URL" | grep -q '"ok"[[:space:]]*:[[:space:]]*true'
}
mark_success() {
local PY
PY="$(wesp_python)"
"$PY" -c "
import sys
from pathlib import Path
root = sys.argv[1]
sys.path.insert(0, root)
from app.services.update_state_store import mark_success, read_update_state
state = read_update_state(root)
mark_success(root, target_version=str(state.get('target_version') or '?'))
Path(root, 'data', '.pending_restart.json').unlink(missing_ok=True)
" "$ROOT"
}
rollback_and_restart() {
local PY
PY="$(wesp_python)"
"$PY" "${ROOT}/scripts/rollback_update.py" --root "$ROOT" || true
stop_service
start_service
}
restart_systemd_unit() {
local UNIT="${WESP_SYSTEMD_UNIT:?}"
echo "post_update: systemctl restart ${UNIT}" >&2
systemctl restart "${UNIT}" || true
}
if [[ -n "${WESP_SYSTEMD_UNIT:-}" ]]; then
restart_systemd_unit
else
stop_service
start_service
fi
deadline=$((SECONDS + HEALTH_TIMEOUT))
while (( SECONDS < deadline )); do
if health_ok; then
mark_success
exit 0
fi
sleep "$HEALTH_INTERVAL"
done
rollback_and_restart
exit 1
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Deprecated: use ./scripts/build_release.sh --wheelhouse-only
exec "$(cd "$(dirname "$0")" && pwd)/build_release.sh" --wheelhouse-only "$@"
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Сброс data/ (БД, настройки, логи) для повторного прохождения /setup.
Запуск из корня проекта (остановите run.py перед сбросом):
python scripts/reset_local_data.py
python scripts/reset_local_data.py --keep-logs
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def main() -> int:
parser = argparse.ArgumentParser(description="Сброс пользовательских данных WESP")
parser.add_argument(
"--keep-logs",
action="store_true",
help="Не удалять каталог data/logs",
)
parser.add_argument(
"--no-safety-backup",
action="store_true",
help="Не создавать ZIP копию SQLite в data/backups/ перед удалением",
)
args = parser.parse_args()
from config import Config
from app import create_app
from app.services.factory_reset import factory_reset_app
app = create_app(Config)
print(f"Сброс: {Config.DATA_DIR}")
with app.app_context():
report = factory_reset_app(
app,
remove_logs=not args.keep_logs,
safety_sqlite_backup=not args.no_safety_backup,
)
for p in report.get("removed") or []:
print(f" удалено: {p}")
for err in report.get("errors") or []:
print(f" ошибка: {err}", file=sys.stderr)
print(report.get("message", "Готово. Перезапустите WESP и откройте /setup."))
return 0 if report.get("ok") else 1
if __name__ == "__main__":
raise SystemExit(main())
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Dev wrapper: post-update with health-check and rollback.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
export WESP_LISTEN="${WESP_LISTEN:-0.0.0.0:80}"
exec bash "${ROOT}/scripts/post_update.sh"
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""Convert ROADMAP.md to PDF (Cyrillic-safe via reportlab)."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from xml.sax.saxutils import escape
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, Preformatted, SimpleDocTemplate, Spacer, Table, TableStyle
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.services.pdf_cyrillic_fonts import ( # noqa: E402
register_cyrillic_font,
register_cyrillic_font_bold,
)
def md_inline(text: str) -> str:
text = escape(text)
text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", text)
text = re.sub(r"`([^`]+)`", r'<font face="Courier">\1</font>', text)
return text
def build_styles(font: str, bold_font: str):
styles = getSampleStyleSheet()
styles.add(
ParagraphStyle(
name="RoadmapTitle",
parent=styles["Title"],
fontName=bold_font,
fontSize=18,
leading=22,
spaceAfter=10,
)
)
styles.add(
ParagraphStyle(
name="RoadmapH2",
parent=styles["Heading2"],
fontName=bold_font,
fontSize=13,
leading=16,
spaceBefore=12,
spaceAfter=6,
)
)
styles.add(
ParagraphStyle(
name="RoadmapH3",
parent=styles["Heading3"],
fontName=bold_font,
fontSize=11,
leading=14,
spaceBefore=8,
spaceAfter=4,
)
)
styles.add(
ParagraphStyle(
name="RoadmapBody",
parent=styles["Normal"],
fontName=font,
fontSize=9,
leading=12,
spaceAfter=4,
)
)
styles.add(
ParagraphStyle(
name="RoadmapQuote",
parent=styles["Normal"],
fontName=font,
fontSize=9,
leading=12,
leftIndent=12,
textColor=colors.HexColor("#374151"),
spaceAfter=6,
)
)
styles.add(
ParagraphStyle(
name="RoadmapBullet",
parent=styles["Normal"],
fontName=font,
fontSize=9,
leading=12,
leftIndent=14,
bulletIndent=6,
spaceAfter=2,
)
)
styles.add(
ParagraphStyle(
name="RoadmapCode",
parent=styles["Code"],
fontName="Courier",
fontSize=7.5,
leading=9.5,
backColor=colors.HexColor("#f3f4f6"),
borderPadding=4,
spaceAfter=6,
)
)
styles.add(
ParagraphStyle(
name="RoadmapTableCell",
parent=styles["Normal"],
fontName=font,
fontSize=8,
leading=10,
)
)
styles.add(
ParagraphStyle(
name="RoadmapTableHeader",
parent=styles["Normal"],
fontName=bold_font,
fontSize=8,
leading=10,
)
)
return styles
def parse_table(lines: list[str], start: int) -> tuple[list[list[str]], int]:
rows: list[list[str]] = []
i = start
while i < len(lines):
line = lines[i].strip()
if not line.startswith("|"):
break
if re.match(r"^\|[-:\s|]+\|$", line):
i += 1
continue
cells = [c.strip() for c in line.strip("|").split("|")]
rows.append(cells)
i += 1
return rows, i
def table_flowable(rows: list[list[str]], styles, usable_width: float):
if not rows:
return None
ncols = max(len(r) for r in rows)
col_width = usable_width / ncols
data = []
for ri, row in enumerate(rows):
style = styles["RoadmapTableHeader"] if ri == 0 else styles["RoadmapTableCell"]
padded = row + [""] * (ncols - len(row))
data.append([Paragraph(md_inline(c), style) for c in padded])
tbl = Table(data, colWidths=[col_width] * ncols, repeatRows=1)
tbl.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#d1d5db")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]
)
)
return tbl
def roadmap_to_pdf(md_path: Path, pdf_path: Path) -> None:
font = register_cyrillic_font()
bold_font = register_cyrillic_font_bold()
styles = build_styles(font, bold_font)
margin = 14 * mm
page_width, _ = A4
usable_width = page_width - 2 * margin
text = md_path.read_text(encoding="utf-8")
lines = text.splitlines()
story = []
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
if not stripped:
i += 1
continue
if stripped == "---":
story.append(Spacer(1, 4))
i += 1
continue
if stripped.startswith("```"):
code_lines = []
i += 1
while i < len(lines) and not lines[i].strip().startswith("```"):
code_lines.append(lines[i])
i += 1
if i < len(lines):
i += 1
code_text = escape("\n".join(code_lines))
story.append(Preformatted(code_text, styles["RoadmapCode"]))
continue
if stripped.startswith("|"):
rows, i = parse_table(lines, i)
tbl = table_flowable(rows, styles, usable_width)
if tbl:
story.append(tbl)
story.append(Spacer(1, 6))
continue
if stripped.startswith("# "):
story.append(Paragraph(md_inline(stripped[2:]), styles["RoadmapTitle"]))
i += 1
continue
if stripped.startswith("## "):
story.append(Paragraph(md_inline(stripped[3:]), styles["RoadmapH2"]))
i += 1
continue
if stripped.startswith("### "):
story.append(Paragraph(md_inline(stripped[4:]), styles["RoadmapH3"]))
i += 1
continue
if stripped.startswith("> "):
story.append(Paragraph(md_inline(stripped[2:]), styles["RoadmapQuote"]))
i += 1
continue
if re.match(r"^[-*]\s+\[[ x]\]", stripped):
content = re.sub(r"^[-*]\s+\[[ x]\]\s*", "", stripped)
story.append(Paragraph(f"{md_inline(content)}", styles["RoadmapBullet"]))
i += 1
continue
if stripped.startswith("- ") or stripped.startswith("* "):
story.append(Paragraph(f"{md_inline(stripped[2:])}", styles["RoadmapBullet"]))
i += 1
continue
if re.match(r"^\d+\.\s", stripped):
story.append(Paragraph(md_inline(stripped), styles["RoadmapBullet"]))
i += 1
continue
story.append(Paragraph(md_inline(stripped), styles["RoadmapBody"]))
i += 1
pdf_path.parent.mkdir(parents=True, exist_ok=True)
doc = SimpleDocTemplate(
str(pdf_path),
pagesize=A4,
leftMargin=margin,
rightMargin=margin,
topMargin=14 * mm,
bottomMargin=14 * mm,
title="WESP — дорожная карта разработки",
)
doc.build(story)
def main() -> int:
root = Path(__file__).resolve().parents[1]
md_path = root / "docs" / "ROADMAP.md"
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.home() / "Downloads" / "WESP-ROADMAP.pdf"
if not md_path.is_file():
print(f"Not found: {md_path}", file=sys.stderr)
return 1
roadmap_to_pdf(md_path, out)
print(out)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""CLI: restore files from backup after failed post-update health check."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.services.update_rollback import restore_from_backup
from app.services.update_state_store import mark_rolled_back, read_update_state
def _pending_path(base: Path) -> Path:
return base / "data" / ".pending_restart.json"
def main() -> int:
parser = argparse.ArgumentParser(description="Rollback WESP from OTA backup")
parser.add_argument("--root", type=Path, default=ROOT)
args = parser.parse_args()
base = args.root.resolve()
state = read_update_state(str(base))
backup_path = state.get("backup_path")
previous_version = state.get("previous_version") or "?"
pending = _pending_path(base)
if pending.is_file():
try:
data = json.loads(pending.read_text(encoding="utf-8"))
if isinstance(data, dict):
backup_path = backup_path or data.get("backup_path")
previous_version = data.get("previous_version") or previous_version
except json.JSONDecodeError:
pass
if not backup_path:
print("rollback_update: no backup_path in state", file=sys.stderr)
return 1
if not restore_from_backup(str(base), str(backup_path)):
return 1
mark_rolled_back(
str(base),
previous_version=str(previous_version),
message=f"Обновление отменено, восстановлена версия {previous_version}",
)
try:
pending.unlink(missing_ok=True)
except OSError:
pass
print(f"rollback_update: restored from {backup_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Ручной запуск разового seed nutrients (обычно не нужен — выполняется при старте WESP)."""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app import create_app
from app.lab.commands.seed_demo_component_nutrients import seed_demo_component_nutrients_once
def main() -> int:
force = "--force" in sys.argv
app = create_app()
with app.app_context():
result = seed_demo_component_nutrients_once(force=force)
print(result)
return 0 if result.get("seeded") or result.get("reason") == "already_done" else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Создать 5 тестовых профилей норм (lab_math_dairy_01 … 05) из data/seed/norms."""
from __future__ import annotations
import sys
from pathlib import Path
_ROOT = Path(__file__).resolve().parents[1]
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from app import create_app, db
from app.lab.commands.seed_math_test_profiles import seed_math_test_profiles
from config import Config
def main() -> int:
app = create_app(Config)
with app.app_context():
stats = seed_math_test_profiles()
print(f"created={stats.created} updated={stats.updated}")
for err in stats.errors:
print("ERROR:", err)
db.session.remove()
return 0 if not stats.errors else 1
if __name__ == "__main__":
raise SystemExit(main())
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Тестовые рецепты LAB: полная матрица + в нормах."""
from __future__ import annotations
import sys
from pathlib import Path
_ROOT = Path(__file__).resolve().parents[1]
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from app import create_app, db
from app.lab.commands.seed_lab_math_ration import RECIPE_IN_NORMS, RECIPE_MATRIX, seed_lab_math_ration
from config import Config
def main() -> int:
force = "--force" in sys.argv
app = create_app(Config)
with app.app_context():
stats = seed_lab_math_ration(force=force)
print(f"created={stats.created} updated={stats.updated} recalculated={stats.recalculated}")
for rid in stats.recipe_ids:
print(f" recipe_id={rid}")
if len(stats.recipe_ids) >= 2:
print(f"Матрица (перегруз): /lab?recipe={stats.recipe_ids[0]}")
print(f"В нормах: /lab?recipe={stats.recipe_ids[1]}")
print(f" {RECIPE_MATRIX}")
print(f" {RECIPE_IN_NORMS}")
for err in stats.errors:
print("ERROR:", err)
db.session.remove()
return 0 if not stats.errors else 1
if __name__ == "__main__":
raise SystemExit(main())
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Нормализация исходных таблиц NORMY_* → JSON в data/seed/racion/."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SOURCE = ROOT.parents[1] / "для разбора и внедрения /тест/RACION_EXPORT/tables/NORMY"
OUT_DIR = ROOT / "data/seed/racion"
def _parse_float_list(raw: str) -> list[float]:
"""POPR_K / Normy — элементы разделены «;», десятичная запятая внутри числа."""
out: list[float] = []
for part in raw.strip().split(";"):
p = part.strip()
if not p:
continue
p = p.replace(",", ".")
try:
out.append(float(p))
except ValueError:
continue
return out
def _read_cp1251(path: Path) -> str:
for enc in ("cp1251", "utf-8", "latin-1"):
try:
return path.read_text(encoding=enc)
except UnicodeDecodeError:
continue
return path.read_text(encoding="latin-1", errors="replace")
def _parse_moskwa_lactir(text: str) -> dict:
rows: list[dict] = []
udoy_boundaries: list[float] = []
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("=") or "NPITV" in line or "Database:" in line:
continue
m = re.match(
r"^\s*([\d.]+)\s+(\d+)\s+([\d.eE+-]+)\s+(.+)$",
line,
)
if not m:
continue
npitv = int(float(m.group(1)))
pom = int(m.group(2))
koef = float(m.group(3))
popr_k = _parse_float_list(m.group(4))
rows.append({"npitv": npitv, "pom": pom, "koef": koef, "popr_k": popr_k})
if npitv == 0 and pom == 2 and koef == 0.0:
udoy_boundaries = popr_k
return {"udoy_boundaries": udoy_boundaries, "rows": rows}
def _parse_piter_lactir(text: str, *, mass_kg_values: list[float] | None = None) -> dict:
entries: list[dict] = []
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("=") or "NPITV" in line or "Database:" in line:
continue
m = re.match(
r"^\s*([\d.]+)\s+([\d.eE+-]+)\s+([\d.eE+-]+)\s+(.+)$",
line,
)
if not m:
continue
npitv = int(float(m.group(1)))
konc = float(m.group(2))
udoy = float(m.group(3))
normy = _parse_float_list(m.group(4))
entries.append({"npitv": npitv, "konc": konc, "udoy": udoy, "normy": normy})
masses = mass_kg_values or [400, 450, 500, 550, 600, 650, 700, 750]
return {"mass_kg_values": masses, "entries": entries}
def _parse_normy_info(text: str) -> dict:
rows: list[dict] = []
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("=") or "NPEREM" in line or "Database:" in line:
continue
m = re.match(r"^\s*(\d+)\s+(.+)$", line)
if not m:
continue
nperem = int(m.group(1))
znachenie = _parse_float_list(m.group(2))
rows.append({"nperem": nperem, "znachenie": znachenie})
by_nperem = {r["nperem"]: r["znachenie"] for r in rows}
mass_kg_values = by_nperem.get(14) or by_nperem.get(13) or [400, 450, 500, 550, 600, 650, 700, 750]
return {"rows": rows, "mass_kg_values": mass_kg_values}
def main() -> int:
source = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_SOURCE
if not source.is_dir():
print(f"Source not found: {source}", file=sys.stderr)
return 1
OUT_DIR.mkdir(parents=True, exist_ok=True)
moskwa_path = source / "NORMY_MOSKWA_LACTIR.txt"
piter_path = source / "NORMY_PITER_LACTIR.txt"
info_path = source / "NORMY_INFO.txt"
if not moskwa_path.exists() or not piter_path.exists():
print(f"Missing NORMY files in {source}", file=sys.stderr)
return 1
normy_info = _parse_normy_info(_read_cp1251(info_path)) if info_path.exists() else {
"rows": [],
"mass_kg_values": [400, 450, 500, 550, 600, 650, 700, 750],
}
moskwa = _parse_moskwa_lactir(_read_cp1251(moskwa_path))
piter = _parse_piter_lactir(
_read_cp1251(piter_path),
mass_kg_values=normy_info.get("mass_kg_values"),
)
(OUT_DIR / "moskwa_lactir.json").write_text(
json.dumps(moskwa, ensure_ascii=False, indent=2), encoding="utf-8"
)
(OUT_DIR / "piter_lactir.json").write_text(
json.dumps(piter, ensure_ascii=False, indent=2), encoding="utf-8"
)
(OUT_DIR / "normy_info.json").write_text(
json.dumps(normy_info, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f"Wrote {OUT_DIR}/moskwa_lactir.json ({len(moskwa['rows'])} rows)")
print(f"Wrote {OUT_DIR}/piter_lactir.json ({len(piter['entries'])} entries)")
print(f"Wrote {OUT_DIR}/normy_info.json ({len(normy_info['rows'])} rows)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Скачивает статические ресурсы UI в static/ (для офлайн-работы без CDN).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
STATIC="${ROOT}/static"
FA_VER="6.5.2"
CHART_VER="4.4.1"
mkdir -p "${STATIC}/fonts/inter" "${STATIC}/fonts/roboto" "${STATIC}/webfonts" "${STATIC}/js" "${STATIC}/icons"
echo "==> Chart.js ${CHART_VER}"
curl -fsSL -o "${STATIC}/js/chart.umd.min.js" \
"https://cdn.jsdelivr.net/npm/chart.js@${CHART_VER}/dist/chart.umd.min.js"
echo "==> Inter (5 начертаний)"
curl -fsSL -o "${STATIC}/fonts/inter/inter-300.ttf" \
"https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuOKfMZg.ttf"
curl -fsSL -o "${STATIC}/fonts/inter/inter-400.ttf" \
"https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfMZg.ttf"
curl -fsSL -o "${STATIC}/fonts/inter/inter-500.ttf" \
"https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuI6fMZg.ttf"
curl -fsSL -o "${STATIC}/fonts/inter/inter-600.ttf" \
"https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuGKYMZg.ttf"
curl -fsSL -o "${STATIC}/fonts/inter/inter-700.ttf" \
"https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYMZg.ttf"
echo "==> Roboto (400/500/700)"
curl -fsSL -o "${STATIC}/fonts/roboto/roboto-400.ttf" \
"https://fonts.gstatic.com/s/roboto/v47/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbWmT.ttf"
curl -fsSL -o "${STATIC}/fonts/roboto/roboto-500.ttf" \
"https://fonts.gstatic.com/s/roboto/v47/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bWmT.ttf"
curl -fsSL -o "${STATIC}/fonts/roboto/roboto-700.ttf" \
"https://fonts.gstatic.com/s/roboto/v47/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjammT.ttf"
echo "==> Font Awesome webfonts ${FA_VER}"
for f in fa-brands-400 fa-regular-400 fa-solid-900 fa-v4compatibility; do
curl -fsSL -o "${STATIC}/webfonts/${f}.woff2" \
"https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@${FA_VER}/webfonts/${f}.woff2"
curl -fsSL -o "${STATIC}/webfonts/${f}.ttf" \
"https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@${FA_VER}/webfonts/${f}.ttf"
done
echo "==> Verify (offline guard)"
"${ROOT}/scripts/verify_static_offline_assets.py"
echo "OK — static assets in ${STATIC}"
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Проверка vendor/wheels против requirements-prod.txt (блокирует сборку ZIP при пробелах)."""
from __future__ import annotations
import argparse
import hashlib
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from install_deps import missing_wheelhouse_packages
def wheelhouse_stats(wheels_dir: Path) -> tuple[int, int]:
"""(число .whl/.tar.gz, суммарный размер байт)."""
count = 0
total = 0
if not wheels_dir.is_dir():
return 0, 0
for path in wheels_dir.iterdir():
if not path.is_file():
continue
if path.name in (".gitkeep", "manifest.json"):
continue
if path.suffix not in (".whl", ".gz", ".zip"):
continue
count += 1
total += path.stat().st_size
return count, total
def verify_offline_closure(req_file: Path, wheels_dir: Path, python_exe: str) -> None:
"""pip install --dry-run только из wheelhouse (все транзитивные deps)."""
cmd = [
python_exe,
"-m",
"pip",
"install",
"--no-index",
f"--find-links={wheels_dir}",
"-r",
str(req_file),
"--dry-run",
"--ignore-installed",
]
subprocess.run(cmd, check=True, capture_output=True, text=True)
def write_manifest(
req_file: Path,
wheels_dir: Path,
manifest_path: Path,
*,
build_arch: str = "",
build_platform: str = "",
) -> None:
req_hash = hashlib.sha256(req_file.read_bytes()).hexdigest() if req_file.is_file() else ""
whl_count, whl_bytes = wheelhouse_stats(wheels_dir)
wheels = sorted(
p.name
for p in wheels_dir.glob("*")
if p.is_file() and p.name not in (".gitkeep", "manifest.json")
)
payload = {
"requirements_sha256": req_hash,
"wheel_count": whl_count,
"wheelhouse_bytes": whl_bytes,
"wheels": wheels,
}
if build_arch:
payload["build_arch"] = build_arch
if build_platform:
payload["build_platform"] = build_platform
manifest_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def format_bytes(n: int) -> str:
if n >= 1024 * 1024:
return f"{n / (1024 * 1024):.1f} MB"
if n >= 1024:
return f"{n / 1024:.0f} KB"
return f"{n} B"
def main() -> int:
parser = argparse.ArgumentParser(description="Verify vendor/wheels for release ZIP")
parser.add_argument("--req", type=Path, default=ROOT / "requirements-prod.txt")
parser.add_argument("--wheels", type=Path, default=ROOT / "vendor" / "wheels")
parser.add_argument("--python", default=sys.executable, help="Python для pip dry-run")
parser.add_argument("--write-manifest", action="store_true")
parser.add_argument("--verify-closure", action="store_true", help="pip dry-run offline install")
parser.add_argument("--build-arch", default="", help="Архитектура build-машины (uname -m)")
parser.add_argument("--build-platform", default="", help="ОС build-машины")
args = parser.parse_args()
missing = missing_wheelhouse_packages(args.req, args.wheels)
whl_count, whl_bytes = wheelhouse_stats(args.wheels)
if args.write_manifest and not missing:
write_manifest(
args.req,
args.wheels,
args.wheels / "manifest.json",
build_arch=args.build_arch.strip(),
build_platform=args.build_platform.strip(),
)
if missing:
print("verify_release_deps: missing wheels for:", file=sys.stderr)
for m in missing:
print(f" - {m}", file=sys.stderr)
return 1
if whl_count == 0:
print("verify_release_deps: wheelhouse empty", file=sys.stderr)
return 1
print(
f"verify_release_deps: OK — {whl_count} packages, "
f"{format_bytes(whl_bytes)} in {args.wheels}"
)
if args.verify_closure:
try:
verify_offline_closure(args.req, args.wheels, args.python)
except subprocess.CalledProcessError as exc:
print("verify_release_deps: offline pip closure FAILED:", file=sys.stderr)
if exc.stderr:
print(exc.stderr, file=sys.stderr)
if exc.stdout:
print(exc.stdout, file=sys.stderr)
return 1
print("verify_release_deps: offline pip closure OK (all deps from wheelhouse)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Проверка: UI-статика не тянет ресурсы с CDN (fonts.gstatic, jsdelivr и т.д.)."""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
STATIC = ROOT / "static"
TEMPLATES = ROOT / "templates"
CDN_IN_CSS = re.compile(r"url\s*\(\s*['\"]?https?://", re.I)
CDN_IN_HTML = re.compile(
r"""(?:src|href)\s*=\s*['"]https?://""",
re.I,
)
REQUIRED_FILES = [
"js/chart.umd.min.js",
"js/wesp-zootech-icons.js",
"icons/route-location.svg",
"icons/cow.svg",
"css/inter-fonts.css",
"css/roboto-fonts.css",
"css/font-awesome.min.css",
"fonts/inter/inter-400.ttf",
"fonts/inter/inter-700.ttf",
"fonts/roboto/roboto-400.ttf",
"webfonts/fa-solid-900.woff2",
"webfonts/fa-solid-900.ttf",
]
def _scan_css(path: Path) -> list[str]:
text = path.read_text(encoding="utf-8", errors="replace")
issues: list[str] = []
for i, line in enumerate(text.splitlines(), 1):
if CDN_IN_CSS.search(line):
issues.append(f"{path.relative_to(ROOT)}:{i}: external url() in CSS")
return issues
def _scan_html(path: Path) -> list[str]:
text = path.read_text(encoding="utf-8", errors="replace")
issues: list[str] = []
for i, line in enumerate(text.splitlines(), 1):
if CDN_IN_HTML.search(line):
issues.append(f"{path.relative_to(ROOT)}:{i}: external src/href in HTML")
return issues
def main() -> int:
issues: list[str] = []
for rel in REQUIRED_FILES:
if not (STATIC / rel).is_file():
issues.append(f"missing required static file: static/{rel}")
css_dirs = [STATIC / "css", STATIC / "vendor"]
for css_dir in css_dirs:
if not css_dir.is_dir():
continue
for path in sorted(css_dir.rglob("*.css")):
issues.extend(_scan_css(path))
for html_root in (STATIC, TEMPLATES):
if not html_root.is_dir():
continue
for path in sorted(html_root.rglob("*.html")):
issues.extend(_scan_html(path))
if issues:
print("verify_static_offline_assets: FAILED", file=sys.stderr)
for item in issues:
print(f" - {item}", file=sys.stderr)
return 1
print("verify_static_offline_assets: OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Единая точка запуска Waitress для systemd, post_update и dev.
# Переменные: WESP_LISTEN (0.0.0.0:80), WESP_WAITRESS_THREADS (по умолчанию 20).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
LISTEN="${WESP_LISTEN:-0.0.0.0:80}"
THREADS="${WESP_WAITRESS_THREADS:-20}"
if ! [[ "$THREADS" =~ ^[0-9]+$ ]] || [[ "$THREADS" -lt 4 ]]; then
THREADS=4
fi
wesp_waitress() {
local venv_dir py
venv_dir="${WESP_VENV:-/opt/wes}"
if [[ -x "${venv_dir}/bin/waitress-serve" ]]; then
echo "${venv_dir}/bin/waitress-serve"
return
fi
py="$(command -v python3 2>/dev/null || true)"
if [[ -n "$py" && -x "${py%/*}/waitress-serve" ]]; then
echo "${py%/*}/waitress-serve"
return
fi
if [[ -x "${ROOT}/../wes_mac/bin/waitress-serve" ]]; then
echo "${ROOT}/../wes_mac/bin/waitress-serve"
return
fi
if [[ -x "${ROOT}/.venv/bin/waitress-serve" ]]; then
echo "${ROOT}/.venv/bin/waitress-serve"
return
fi
command -v waitress-serve
}
WAITRESS="$(wesp_waitress)"
exec "$WAITRESS" --listen="$LISTEN" --threads="$THREADS" wsgi:app