@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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 без пароля.
|
||||
@@ -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 с) |
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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}"
|
||||
@@ -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())
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user