Ajoute "Publier" : exporte un jeu en exécutable Windows autonome
Fonctionnalité mise de côté depuis le tout début de ce chantier
("jouable en toute autonomie"). Nouveau bouton "📦 Publier" dans la barre
de navigation du jeu (après "▶️ Jouer") : construit un zip contenant un
Python portable + Flask embarqués, une copie figée du moteur de rendu
(screens/db/filters + le strict minimum de core/) et des données du jeu
(game.db + uploads/), lancé en double-cliquant sur run.bat — aucune
installation requise, 100% hors-ligne (voir le commit précédent qui a
auto-hébergé polices/animate.css, dernière dépendance CDN de l'app).
Exploration préalable a confirmé que screens/ et db/ sont déjà
totalement découplés de auth/routes/core (seul lien : un try/except
optionnel dans db/connection.py) et que templates/play.html n'appelle
que 4 endpoints "purs" (aucune logique d'auth mélangée dedans) — ce qui
a permis de réutiliser ces routes quasiment telles quelles dans un
mini-serveur Flask séparé plutôt que de les réécrire.
- db/constants.py : PROJECTS_DIR devient surchargeable via
FORGE_PROJECTS_DIR (même schéma que auth/connection.py) — le serveur
joueur autonome pointe ainsi vers son propre dossier "projects/"
embarqué.
- publish/vendor_runtime.py : télécharge (une fois par poste, mis en
cache sous data/publish_vendor/ — déjà ignoré par git) le ZIP Python
embeddable officiel (python.org) et vendore Flask via pip --target ;
fonctions séparées et mockables pour ne jamais déclencher de vrai
téléchargement dans les tests.
- publish/player_app_template.py : mini-Flask autonome, réutilise
core/flask_app.py et core/jinja_filters.py tels quels (SLUG figé en
dur au moment de la publication, csrf_token() factice puisqu'aucune
session n'existe dans cet export). sys.path doit être complété
manuellement au démarrage : le python311._pth de la distribution
embeddable ne référence que le dossier de python.exe lui-même, jamais
celui du script lancé.
- publish/build_package.py : assemble le zip dans un dossier temporaire
(jamais les vrais fichiers de l'app), copié/nettoyé après envoi de la
réponse HTTP (routes/publish/publish_game.py, déjà protégée par la
garde d'accès existante — aucune vérification supplémentaire).
- templates/base.html : bouton + modale (avancement séquentiel, jamais
de suivi serveur réel — le build est rapide) qui déclenche le
téléchargement du zip via un blob, comme la modale des codes de
récupération déjà en place (posée À L'INTÉRIEUR de <main> pour que
pjax.js la remplace et rejoue son script à chaque navigation).
Vérifié pour de vrai (pas seulement via les tests) : zip construit,
extrait, lancé avec le Python embeddable réel — /, /game/test/play,
/game/test/runtime-payload et /static/style.css répondent tous 200.
tests/test_publish.py (nouveau, vendor mocké) : structure du zip,
SLUG correctement substitué, route protégée par la même isolation par
projet que le reste de l'app. 203 tests au total.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5d6747770c
commit
1289079da5
@@ -0,0 +1,3 @@
|
||||
"""publish — empaquette un jeu en exécutable Windows autonome (zip avec
|
||||
serveur interne, voir build_package.py) : le bouton "Publier" de la barre
|
||||
de navigation d'un jeu (templates/base.html)."""
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Assemble le zip exporté d'un jeu (bouton "Publier", voir
|
||||
routes/publish/publish_game.py) : un dossier autonome contenant Python
|
||||
portable + Flask (vendorés une fois, voir vendor_runtime.py), une copie
|
||||
figée du jeu (screens/db/filters/core minimal + templates/play.html +
|
||||
le sous-ensemble utile de static/), sa base SQLite et ses fichiers
|
||||
envoyés (uploads/), et les scripts de lancement (player_app.py/run.bat).
|
||||
|
||||
Ne modifie JAMAIS les fichiers réels de l'app (tout se passe dans un
|
||||
dossier de travail temporaire, supprimé après envoi de la réponse) ni la
|
||||
vraie base de données du jeu (copiée, jamais déplacée)."""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
import db
|
||||
|
||||
from . import vendor_runtime
|
||||
|
||||
_PUBLISH_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_BASE_DIR = os.path.dirname(_PUBLISH_DIR)
|
||||
|
||||
# Sous-ensemble de static/ réellement nécessaire au mode jouable (voir
|
||||
# templates/play.html) — jamais les assets propres à l'éditeur seul
|
||||
# (icônes de l'interface de l'éditeur mises à part, déjà dans icons/).
|
||||
_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css"]
|
||||
|
||||
_SLUG_PLACEHOLDER = "_SLUG_PLACEHOLDER"
|
||||
|
||||
|
||||
def _copy_engine_sources(staging_dir):
|
||||
"""Copie la partie du moteur nécessaire au mode jouable SEUL (jamais
|
||||
l'éditeur ni l'authentification — voir le constat d'exploration dans
|
||||
le plan : screens/ et db/ sont déjà totalement découplés de auth//
|
||||
routes/, core/flask_app.py et core/jinja_filters.py sont les 2 SEULS
|
||||
fichiers de core/ dont ils dépendent réellement)."""
|
||||
for pkg in ("screens", "db", "filters"):
|
||||
shutil.copytree(os.path.join(_BASE_DIR, pkg), os.path.join(staging_dir, pkg),
|
||||
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||
|
||||
core_dir = os.path.join(staging_dir, "core")
|
||||
os.makedirs(core_dir, exist_ok=True)
|
||||
for fname in ("__init__.py", "flask_app.py", "jinja_filters.py"):
|
||||
shutil.copy2(os.path.join(_BASE_DIR, "core", fname), os.path.join(core_dir, fname))
|
||||
|
||||
templates_dir = os.path.join(staging_dir, "templates")
|
||||
os.makedirs(templates_dir, exist_ok=True)
|
||||
shutil.copy2(os.path.join(_BASE_DIR, "templates", "play.html"), os.path.join(templates_dir, "play.html"))
|
||||
|
||||
static_dir = os.path.join(staging_dir, "static")
|
||||
for item in _STATIC_ITEMS:
|
||||
src = os.path.join(_BASE_DIR, "static", item)
|
||||
dst = os.path.join(static_dir, item)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
if os.path.isdir(src):
|
||||
shutil.copytree(src, dst)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def _copy_game_data(staging_dir, slug):
|
||||
"""Copie le dossier RÉEL du jeu (game.db + uploads/, voir
|
||||
db.game_dir) tel quel — jamais déplacé, jamais modifié."""
|
||||
src = db.game_dir(slug)
|
||||
dst = os.path.join(staging_dir, "projects", slug)
|
||||
shutil.copytree(src, dst, ignore=shutil.ignore_patterns("index.html", "index.css", "index.js"))
|
||||
|
||||
|
||||
def _write_player_app(staging_dir, slug):
|
||||
template_path = os.path.join(_PUBLISH_DIR, "player_app_template.py")
|
||||
with open(template_path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
content = content.replace(_SLUG_PLACEHOLDER, slug)
|
||||
with open(os.path.join(staging_dir, "player_app.py"), "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def _write_run_bat(staging_dir):
|
||||
src = os.path.join(_PUBLISH_DIR, "run_bat_template.bat")
|
||||
shutil.copy2(src, os.path.join(staging_dir, "run.bat"))
|
||||
|
||||
|
||||
def _zip_dir(staging_dir, zip_path):
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, _dirs, files in os.walk(staging_dir):
|
||||
for name in files:
|
||||
full = os.path.join(root, name)
|
||||
rel = os.path.relpath(full, staging_dir)
|
||||
zf.write(full, rel)
|
||||
|
||||
|
||||
def build_game_zip(slug):
|
||||
"""Construit le zip et renvoie son chemin (dans un dossier temporaire
|
||||
— à l'appelant de le supprimer une fois la réponse HTTP envoyée, voir
|
||||
routes/publish/publish_game.py)."""
|
||||
game = db.game_meta(slug)
|
||||
python_dir, pylibs_dir = vendor_runtime.ensure_vendor_ready()
|
||||
|
||||
staging_dir = tempfile.mkdtemp(prefix="forge_publish_")
|
||||
try:
|
||||
shutil.copytree(python_dir, os.path.join(staging_dir, "python-embed"))
|
||||
shutil.copytree(pylibs_dir, os.path.join(staging_dir, "python-embed", "pylibs"))
|
||||
_copy_engine_sources(staging_dir)
|
||||
_copy_game_data(staging_dir, slug)
|
||||
_write_player_app(staging_dir, slug)
|
||||
_write_run_bat(staging_dir)
|
||||
|
||||
zip_fd, zip_path = tempfile.mkstemp(prefix="forge_publish_", suffix=".zip")
|
||||
os.close(zip_fd)
|
||||
_zip_dir(staging_dir, zip_path)
|
||||
finally:
|
||||
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||
return zip_path, game["name"]
|
||||
@@ -0,0 +1,107 @@
|
||||
"""player_app.py — serveur autonome généré par Forge Engine pour UN SEUL
|
||||
jeu (voir publish/build_package.py) : aucune installation externe requise,
|
||||
lancé par double-clic sur run.bat (Python portable + Flask embarqués à
|
||||
côté de ce fichier). Reprend telles quelles les routes utilisées par le
|
||||
mode jouable (voir templates/play.html) — jamais l'éditeur ni
|
||||
l'authentification, absents de cet export.
|
||||
|
||||
SLUG est remplacé littéralement par build_package.py au moment de la
|
||||
publication (voir _SLUG_PLACEHOLDER) — ce fichier n'est jamais exécuté
|
||||
tel quel depuis le dépôt source."""
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import webbrowser
|
||||
|
||||
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Le python311._pth de Python embeddable (voir publish/vendor_runtime.py)
|
||||
# ne met sur sys.path QUE le dossier de python.exe lui-même (python-embed/)
|
||||
# et "pylibs" — jamais le dossier de CE script, qui vit un cran au-dessus
|
||||
# (sibling de python-embed/). Sans cette ligne, "import core"/"db"/
|
||||
# "screens" échoue avec ModuleNotFoundError bien qu'ils soient juste à
|
||||
# côté de ce fichier.
|
||||
sys.path.insert(0, _BASE_DIR)
|
||||
|
||||
# Doit être posé AVANT tout import de db/ : db/constants.py lit
|
||||
# FORGE_PROJECTS_DIR à l'IMPORT, pas à l'appel — pointe ici vers le
|
||||
# dossier "projects/" embarqué à côté de ce fichier, jamais un chemin du
|
||||
# poste où le jeu a été publié.
|
||||
os.environ["FORGE_PROJECTS_DIR"] = os.path.join(_BASE_DIR, "projects")
|
||||
|
||||
from flask import jsonify, redirect, render_template, request, send_from_directory, url_for # noqa: E402
|
||||
|
||||
from core.flask_app import app # noqa: E402
|
||||
from core import jinja_filters # noqa: E402,F401 - enregistre les filtres Jinja (elstyle/eltransform/elabel/colname)
|
||||
import db # noqa: E402
|
||||
import screens # noqa: E402
|
||||
|
||||
# Pas de CSRF/auth dans cet export (un seul jeu, aucune connexion) —
|
||||
# play.html appelle quand même csrf_token() en Jinja (voir base.html/
|
||||
# play.html) : un no-op le laisse fonctionner tel quel, sans le forker.
|
||||
app.jinja_env.globals["csrf_token"] = lambda: ""
|
||||
|
||||
SLUG = "_SLUG_PLACEHOLDER"
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return redirect(url_for("game_play", slug=SLUG))
|
||||
|
||||
|
||||
@app.route("/game/<slug>/play")
|
||||
def game_play(slug):
|
||||
game = db.game_meta(SLUG)
|
||||
payload = screens.full_game_payload(SLUG)
|
||||
return render_template(
|
||||
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/game/<slug>/runtime-payload")
|
||||
def runtime_payload(slug):
|
||||
return jsonify(screens.full_game_payload(SLUG))
|
||||
|
||||
|
||||
@app.route("/game/<slug>/flow/nodes/<int:node_id>/run-data", methods=["POST"])
|
||||
def flow_node_run_data(slug, node_id):
|
||||
node = screens.get_flow_node(SLUG, node_id)
|
||||
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee":
|
||||
return jsonify({"ok": False}), 400
|
||||
if node.get("target_row_id") == screens.CLICKED_ROW_ID:
|
||||
body = request.get_json(silent=True) or {}
|
||||
node = {**node, "target_row_id": body.get("clicked_row_id")}
|
||||
ok = screens.apply_data_action(SLUG, node)
|
||||
return jsonify({"ok": ok})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/flow/nodes/<int:node_id>/run-variable", methods=["POST"])
|
||||
def flow_node_run_variable(slug, node_id):
|
||||
node = screens.get_flow_node(SLUG, node_id)
|
||||
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_variable":
|
||||
return jsonify({"ok": False}), 400
|
||||
ok = screens.apply_variable_action(SLUG, node)
|
||||
return jsonify({"ok": ok})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/uploads/<path:filename>")
|
||||
def uploaded_file(slug, filename):
|
||||
return send_from_directory(os.path.join(db.game_dir(SLUG), "uploads"), filename)
|
||||
|
||||
|
||||
def _open_browser(port):
|
||||
webbrowser.open(f"http://127.0.0.1:{port}/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Port libre choisi au hasard par l'OS (jamais le même serveur à
|
||||
# relancer sur un port déjà pris par une partie précédente encore
|
||||
# ouverte) — le navigateur est ouvert automatiquement, l'utilisateur
|
||||
# n'a jamais besoin de connaître ni taper le port.
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
threading.Timer(1.0, _open_browser, args=(port,)).start()
|
||||
app.run(host="127.0.0.1", port=port, debug=False, use_reloader=False)
|
||||
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
python-embed\python.exe player_app.py
|
||||
pause
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Prépare, une seule fois par poste, tout ce qu'il faut pour empaqueter
|
||||
un jeu en exécutable autonome : Python portable (embeddable, python.org)
|
||||
et les paquets pip nécessaires (Flask + ses dépendances), mis en cache
|
||||
localement sous data/publish_vendor/ (non commité, voir .gitignore —
|
||||
même dossier que data/secret_key/data/users.db, déjà runtime-only).
|
||||
|
||||
La toute PREMIÈRE publication d'un jeu sur un poste donné télécharge ces
|
||||
~15 Mo (connexion internet nécessaire, une seule fois) ; toutes les
|
||||
publications suivantes réutilisent le cache, hors-ligne. Le JOUEUR qui
|
||||
reçoit le zip final, lui, n'a jamais besoin d'internet — ce cache ne
|
||||
concerne que la machine qui PUBLIE.
|
||||
|
||||
Fonctions séparées et mockables (download_python_embeddable/
|
||||
vendor_flask) pour que les tests ne déclenchent jamais un vrai
|
||||
téléchargement."""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
VENDOR_DIR = os.environ.get("FORGE_PUBLISH_VENDOR_DIR") or os.path.join(_BASE_DIR, "data", "publish_vendor")
|
||||
|
||||
PYTHON_VERSION = "3.11.9"
|
||||
PYTHON_EMBED_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip"
|
||||
PYTHON_EMBED_DIR = os.path.join(VENDOR_DIR, "python-embed-amd64")
|
||||
PYLIBS_DIR = os.path.join(VENDOR_DIR, "pylibs")
|
||||
|
||||
# Mêmes versions que requirements.txt — seul Flask est listé explicitement,
|
||||
# ses dépendances (Werkzeug/Jinja2/MarkupSafe/click/itsdangerous/blinker)
|
||||
# suivent automatiquement via pip.
|
||||
FLASK_SPEC = "Flask==3.0.3"
|
||||
|
||||
|
||||
def download_python_embeddable():
|
||||
"""Télécharge et extrait le ZIP Python embeddable officiel si absent
|
||||
du cache — no-op si déjà présent (idempotent, sûr à rappeler à
|
||||
chaque publication)."""
|
||||
marker = os.path.join(PYTHON_EMBED_DIR, "python.exe")
|
||||
if os.path.isfile(marker):
|
||||
return PYTHON_EMBED_DIR
|
||||
os.makedirs(PYTHON_EMBED_DIR, exist_ok=True)
|
||||
zip_path = os.path.join(VENDOR_DIR, "python-embed-amd64.zip")
|
||||
urllib.request.urlretrieve(PYTHON_EMBED_URL, zip_path)
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
zf.extractall(PYTHON_EMBED_DIR)
|
||||
os.remove(zip_path)
|
||||
_enable_pylibs_path(PYTHON_EMBED_DIR)
|
||||
return PYTHON_EMBED_DIR
|
||||
|
||||
|
||||
def _enable_pylibs_path(python_embed_dir):
|
||||
"""Le ZIP embeddable désactive site-packages par défaut
|
||||
(python311._pth ne contient que "python311.zip" et "."). On ajoute une
|
||||
ligne "pylibs" (chemin relatif à python.exe) plutôt que de réactiver
|
||||
tout le mécanisme "site" — les paquets vendorés (Flask...) sont de
|
||||
simples dossiers purement Python, aucun besoin d'aller plus loin."""
|
||||
pth_files = [f for f in os.listdir(python_embed_dir) if f.endswith("._pth")]
|
||||
if not pth_files:
|
||||
return
|
||||
pth_path = os.path.join(python_embed_dir, pth_files[0])
|
||||
with open(pth_path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if "pylibs" not in content:
|
||||
with open(pth_path, "a", encoding="utf-8") as f:
|
||||
f.write("\npylibs\n")
|
||||
|
||||
|
||||
def vendor_flask():
|
||||
"""pip install --target=... : installe Flask (paquets purs Python,
|
||||
indépendants de la version exacte de l'interpréteur embeddable) dans
|
||||
le cache, réutilisé par tous les exports suivants — no-op si déjà
|
||||
fait."""
|
||||
if os.path.isdir(PYLIBS_DIR) and os.listdir(PYLIBS_DIR):
|
||||
return PYLIBS_DIR
|
||||
os.makedirs(PYLIBS_DIR, exist_ok=True)
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--target", PYLIBS_DIR, "--no-compile", FLASK_SPEC],
|
||||
check=True,
|
||||
)
|
||||
return PYLIBS_DIR
|
||||
|
||||
|
||||
def ensure_vendor_ready():
|
||||
"""Point d'entrée unique appelé par build_package.py — télécharge/
|
||||
installe ce qui manque, puis renvoie (dossier_python_embed,
|
||||
dossier_pylibs)."""
|
||||
python_dir = download_python_embeddable()
|
||||
pylibs_dir = vendor_flask()
|
||||
return python_dir, pylibs_dir
|
||||
Reference in New Issue
Block a user