Retire l'export exécutable (.exe) et la partie publique en ligne (/jouer)
Décision produit : seul l'export Web/SCORM (LMS) est pertinent — l'export exécutable Windows autonome (publish/build_package.py, bouton "Publier") et la partie publique par joueur (/jouer/<slug>, routes/public_play/, "Publier en ligne") sont jugés redondants et retirés. Conserve le mécanisme d'état "par joueur" (db/global_vars, db/rows, per_player) : infrastructure générique déjà utilisée par Score/Progression et testée indépendamment de toute route publique (voir tests/test_player_state.py), aucune raison de la retirer. _STATIC_ITEMS/_copy_characters (copie sélective des sprites CraftPix réellement utilisés) migrent de build_package.py vers build_scorm_package.py, seul appelant restant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
82d00b3800
commit
e82eb7ce88
+2
-2
@@ -1,3 +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
|
||||
"""publish — empaquette un jeu en export Web/SCORM (voir
|
||||
build_scorm_package.py) : le bouton "Exporter (Web/SCORM)" de la barre
|
||||
de navigation d'un jeu (templates/base.html)."""
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
"""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
|
||||
import screens
|
||||
|
||||
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", "js/play", "characters"]
|
||||
|
||||
_SLUG_PLACEHOLDER = "_SLUG_PLACEHOLDER"
|
||||
|
||||
|
||||
def _copy_characters(static_dir, used_forge_characters):
|
||||
"""`static/characters/` contient 2 rangements bien distincts (voir le
|
||||
plan "Bibliothèque de sprites animaux CraftPix") : le rangement plat
|
||||
Kenney (`<slug>/*.png`, CC0, ~6 Mo, toujours copié comme avant) et
|
||||
`animals/<animal>/<NN>/` (CraftPix, ~1,5-2 Go pour les 210 variantes)
|
||||
— celui-ci copié SÉLECTIVEMENT, seulement les dossiers (animal, NN)
|
||||
correspondant aux personnages `animal-<animal>-<NN>` réellement
|
||||
utilisés par CE jeu (used_forge_characters), pour ne pas gonfler
|
||||
CHAQUE .zip exporté du poids de toute la bibliothèque. manifest.json
|
||||
est toujours copié : screens/labels/animal_sprite_library.py le lit à
|
||||
l'import, sans lui `screens` (copié intégralement juste après) ne
|
||||
s'importerait plus dans le paquet exporté."""
|
||||
src_root = os.path.join(_BASE_DIR, "static", "characters")
|
||||
dst_root = os.path.join(static_dir, "characters")
|
||||
for name in os.listdir(src_root):
|
||||
if name == "animals":
|
||||
continue
|
||||
src = os.path.join(src_root, name)
|
||||
dst = os.path.join(dst_root, name)
|
||||
if os.path.isdir(src):
|
||||
shutil.copytree(src, dst)
|
||||
else:
|
||||
os.makedirs(dst_root, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
animals_src_root = os.path.join(src_root, "animals")
|
||||
if not os.path.isdir(animals_src_root):
|
||||
return
|
||||
animals_dst_root = os.path.join(dst_root, "animals")
|
||||
os.makedirs(animals_dst_root, exist_ok=True)
|
||||
shutil.copy2(os.path.join(animals_src_root, "manifest.json"), os.path.join(animals_dst_root, "manifest.json"))
|
||||
used_variants = set()
|
||||
for slug in used_forge_characters:
|
||||
# slug = "animal-<animal>-<NN>" (voir screens/labels/animal_sprite_library.py) ;
|
||||
# ignore silencieusement tout ce qui ne colle pas à ce format —
|
||||
# un personnage Kenney (déjà copié ci-dessus) n'a pas ce préfixe.
|
||||
if not slug.startswith("animal-"):
|
||||
continue
|
||||
animal, _, nn = slug[len("animal-"):].rpartition("-")
|
||||
if animal:
|
||||
used_variants.add((animal, nn))
|
||||
for animal, nn in used_variants:
|
||||
src = os.path.join(animals_src_root, animal, nn)
|
||||
if os.path.isdir(src):
|
||||
shutil.copytree(src, os.path.join(animals_dst_root, animal, nn))
|
||||
|
||||
|
||||
def _copy_engine_sources(staging_dir, slug):
|
||||
"""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")
|
||||
used_forge_characters = screens.list_used_forge_characters(slug)
|
||||
for item in _STATIC_ITEMS:
|
||||
if item == "characters":
|
||||
_copy_characters(static_dir, used_forge_characters)
|
||||
continue
|
||||
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, slug)
|
||||
_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"]
|
||||
@@ -1,12 +1,8 @@
|
||||
"""Assemble le zip Web/SCORM d'un jeu (bouton "Exporter (Web/SCORM)", voir
|
||||
routes/publish/export_scorm.py) : à la différence de build_package.py
|
||||
(qui embarque Python + Flask, voir player_app.py), ce paquet est
|
||||
STATIQUE — index.html porte tout le runtime jouable directement en
|
||||
mémoire (window.FORGE_OFFLINE, voir static/js/play/offline/) puisqu'un
|
||||
SCORM tourne SEUL dans le LMS du client, sans serveur Forge Engine
|
||||
disponible. Rien de commun avec le zip .exe hors le sous-ensemble de
|
||||
static/ et _copy_characters (mêmes assets, mêmes règles de copie
|
||||
sélective) — réutilisés depuis build_package plutôt que dupliqués."""
|
||||
routes/publish/export_scorm.py) — paquet STATIQUE : index.html porte tout
|
||||
le runtime jouable directement en mémoire (window.FORGE_OFFLINE, voir
|
||||
static/js/play/offline/) puisqu'un SCORM tourne SEUL dans le LMS du
|
||||
client, sans serveur Forge Engine disponible."""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
@@ -19,12 +15,65 @@ from flask import render_template
|
||||
import db
|
||||
import screens
|
||||
|
||||
from .build_package import _copy_characters, _STATIC_ITEMS
|
||||
from .scorm_manifest import build_manifest_xml
|
||||
|
||||
_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", "js/play", "characters"]
|
||||
|
||||
|
||||
def _copy_characters(static_dir, used_forge_characters):
|
||||
"""`static/characters/` contient 2 rangements bien distincts (voir le
|
||||
plan "Bibliothèque de sprites animaux CraftPix") : le rangement plat
|
||||
Kenney (`<slug>/*.png`, CC0, ~6 Mo, toujours copié comme avant) et
|
||||
`animals/<animal>/<NN>/` (CraftPix, ~1,5-2 Go pour les 210 variantes)
|
||||
— celui-ci copié SÉLECTIVEMENT, seulement les dossiers (animal, NN)
|
||||
correspondant aux personnages `animal-<animal>-<NN>` réellement
|
||||
utilisés par CE jeu (used_forge_characters), pour ne pas gonfler
|
||||
CHAQUE .zip exporté du poids de toute la bibliothèque. manifest.json
|
||||
est toujours copié : screens/labels/animal_sprite_library.py le lit à
|
||||
l'import, sans lui `screens` (copié intégralement juste après) ne
|
||||
s'importerait plus dans le paquet exporté."""
|
||||
src_root = os.path.join(_BASE_DIR, "static", "characters")
|
||||
dst_root = os.path.join(static_dir, "characters")
|
||||
for name in os.listdir(src_root):
|
||||
if name == "animals":
|
||||
continue
|
||||
src = os.path.join(src_root, name)
|
||||
dst = os.path.join(dst_root, name)
|
||||
if os.path.isdir(src):
|
||||
shutil.copytree(src, dst)
|
||||
else:
|
||||
os.makedirs(dst_root, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
animals_src_root = os.path.join(src_root, "animals")
|
||||
if not os.path.isdir(animals_src_root):
|
||||
return
|
||||
animals_dst_root = os.path.join(dst_root, "animals")
|
||||
os.makedirs(animals_dst_root, exist_ok=True)
|
||||
shutil.copy2(os.path.join(animals_src_root, "manifest.json"), os.path.join(animals_dst_root, "manifest.json"))
|
||||
used_variants = set()
|
||||
for character_slug in used_forge_characters:
|
||||
# character_slug = "animal-<animal>-<NN>" (voir
|
||||
# screens/labels/animal_sprite_library.py) ; ignore silencieusement
|
||||
# tout ce qui ne colle pas à ce format — un personnage Kenney
|
||||
# (déjà copié ci-dessus) n'a pas ce préfixe.
|
||||
if not character_slug.startswith("animal-"):
|
||||
continue
|
||||
animal, _, nn = character_slug[len("animal-"):].rpartition("-")
|
||||
if animal:
|
||||
used_variants.add((animal, nn))
|
||||
for animal, nn in used_variants:
|
||||
src = os.path.join(animals_src_root, animal, nn)
|
||||
if os.path.isdir(src):
|
||||
shutil.copytree(src, os.path.join(animals_dst_root, animal, nn))
|
||||
|
||||
|
||||
def _copy_static_assets(staging_dir, slug):
|
||||
static_dir = os.path.join(staging_dir, "static")
|
||||
used_forge_characters = screens.list_used_forge_characters(slug)
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,4 +0,0 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
python-embed\python.exe player_app.py
|
||||
pause
|
||||
@@ -1,91 +0,0 @@
|
||||
"""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