feat: preparation release v2.0.0 - guide dev/releases, script publication Gitea, build NSIS+MSI
CI / lint (push) Successful in 35s
CI / security (push) Successful in 22s
CI / test (push) Successful in 37s
CI / build (push) Successful in 13s
CI / e2e (push) Successful in 5m40s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 35s
CI / security (push) Successful in 22s
CI / test (push) Successful in 37s
CI / build (push) Successful in 13s
CI / e2e (push) Successful in 5m40s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
This commit is contained in:
@@ -52,11 +52,15 @@ xcopy /E /I /Q /Y "..\frontend" "frontend"
|
||||
echo ✅ Staged
|
||||
|
||||
REM ── 3. Build Tauri ────────────────────────────────────────────
|
||||
echo [3/6] Building Tauri application (NSIS per-user installer)...
|
||||
cargo tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
echo [3/6] Building Tauri application (NSIS + MSI installers)...
|
||||
cargo tauri build --target x86_64-pc-windows-msvc --bundles nsis,msi
|
||||
if errorlevel 1 (
|
||||
echo ⚠️ Standard bundle build failed, trying fallback to NSIS only...
|
||||
cargo tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
if errorlevel 1 (
|
||||
echo ❌ Build failed!
|
||||
goto :cleanup
|
||||
)
|
||||
)
|
||||
echo ✅ Build successful
|
||||
|
||||
@@ -76,8 +80,9 @@ if exist "frontend" rmdir /s /q "frontend"
|
||||
echo ✅ Cleaned
|
||||
|
||||
REM ── 6. Résultats ───────────────────────────────────────────────
|
||||
echo [6/6] Results:
|
||||
echo [6/6] Binaries generated:
|
||||
dir /b "%OUTDIR%\bundle\nsis\*.exe" 2>nul
|
||||
dir /b "%OUTDIR%\bundle\msi\*.msi" 2>nul
|
||||
echo.
|
||||
echo ✅ Done! Installer: %OUTDIR%\bundle\nsis\
|
||||
echo ✅ Done! Installers generated in: %OUTDIR%\bundle\
|
||||
endlocal
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Guide de Développement, Build Local & Publication des Releases Gitea — ObsiGate
|
||||
|
||||
Ce guide décrit la procédure pas-à-pas pour **tester**, **compiler localement** l'application Desktop (Windows et Linux), **effectuer les commits & pushs**, puis **publier les binaires** sur la section **Releases / Publications** de Gitea (`https://git.dracodev.net/Projets/ObsiGate/releases`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Tests et Prévisualisation Locale
|
||||
|
||||
### A. Backend & Frontend (Tests unitaires & Linting)
|
||||
```powershell
|
||||
# Tests backend Python (FastAPI)
|
||||
.\.venv\Scripts\python.exe -m pytest tests/
|
||||
|
||||
# Linting backend (ruff & mypy)
|
||||
.\.venv\Scripts\python.exe -m ruff check backend/
|
||||
.\.venv\Scripts\python.exe -m mypy backend/ --ignore-missing-imports
|
||||
|
||||
# Validation & tests unitaires frontend
|
||||
node tests/frontend/validate-imports.mjs
|
||||
node tests/frontend/unit.test.mjs
|
||||
```
|
||||
|
||||
### B. Mode Développement Desktop (Tauri 2 Live Preview)
|
||||
Pour développer l'application desktop avec rechargement à chaud (Hot Reload) :
|
||||
```powershell
|
||||
cd desktop
|
||||
cargo tauri dev
|
||||
```
|
||||
*Le backend Python et l'interface WebView Tauri démarreront automatiquement sur `http://127.0.0.1:17890`.*
|
||||
|
||||
---
|
||||
|
||||
## 2. Compilation Locale de l'Application Desktop
|
||||
|
||||
### A. Windows (Installeur `.exe` NSIS et Paquet `.msi`)
|
||||
1. Ouvrez un terminal PowerShell ou CMD sur votre machine Windows.
|
||||
2. Déplacez-vous dans le dossier `desktop/` et exécutez le script :
|
||||
```powershell
|
||||
cd desktop
|
||||
.\build-windows.bat
|
||||
```
|
||||
3. **Binaires générés dans** `desktop/target/x86_64-pc-windows-msvc/release/bundle/` :
|
||||
- `ObsiGate_x.y.z_x64-setup.exe` (Installeur utilisateur NSIS)
|
||||
- `ObsiGate_x.y.z_x64_en-US.msi` (Paquet d'installation MSI)
|
||||
|
||||
### B. Linux (Exécutable `.AppImage` et Paquet `.deb`)
|
||||
1. Ouvrez un terminal Bash (Machine Linux ou WSL2 / Conteneur Docker).
|
||||
2. Déplacez-vous dans le dossier `desktop/` et exécutez le script :
|
||||
```bash
|
||||
cd desktop
|
||||
chmod +x build-linux.sh
|
||||
./build-linux.sh
|
||||
```
|
||||
3. **Binaires générés dans** `desktop/target/x86_64-unknown-linux-gnu/release/bundle/` :
|
||||
- `ObsiGate_x.y.z_amd64.AppImage` (Exécutable portable Linux)
|
||||
- `obsigate_x.y.z_amd64.deb` (Paquet Debian/Ubuntu)
|
||||
|
||||
---
|
||||
|
||||
## 3. Commit, Push & Tagging Git
|
||||
|
||||
Une fois les modifications testées et validées :
|
||||
|
||||
1. **Commit et Push du code source** :
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: préparation release v2.0.0"
|
||||
git push origin main
|
||||
```
|
||||
*Ceci déclenchera la vérification CI standard sur Gitea (lint, tests unitaires, build Docker).*
|
||||
|
||||
2. **Création du Tag de Version** :
|
||||
```bash
|
||||
git tag -a v2.0.0 -m "Release v2.0.0"
|
||||
git push origin v2.0.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Publication des Binaires sur Gitea Releases
|
||||
|
||||
Un script automatisé (`scripts/publish_release.py`) se charge de téléverser vos binaires compilés localement et de générer l'empreinte cryptographique `checksums.txt` (SHA256).
|
||||
|
||||
### A. Obtenir un Jeton d'Accès Gitea (Une seule fois)
|
||||
1. Connectez-vous sur votre instance Gitea : `https://git.dracodev.net`.
|
||||
2. Cliquez sur votre **Profil** $\rightarrow$ **Paramètres** $\rightarrow$ **Applications**.
|
||||
3. Dans **Gérer les jetons d'accès personnels**, entrez le nom `ObsiGate-Release` et cochez le jeton avec accès en écriture sur les dépôts (`repo`).
|
||||
4. Copiez le jeton généré.
|
||||
|
||||
### B. Exécution de la Publication
|
||||
|
||||
**Option 1 — Définir la variable d'environnement (Recommandé) :**
|
||||
- **Windows PowerShell** :
|
||||
```powershell
|
||||
$env:GITEA_TOKEN="votre_jeton_gitea_ici"
|
||||
.\.venv\Scripts\python.exe scripts/publish_release.py --tag v2.0.0
|
||||
```
|
||||
- **Linux / Bash** :
|
||||
```bash
|
||||
export GITEA_TOKEN="votre_jeton_gitea_ici"
|
||||
python3 scripts/publish_release.py --tag v2.0.0
|
||||
```
|
||||
|
||||
**Option 2 — Utiliser l'option `--token` :**
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe scripts/publish_release.py --tag v2.0.0 --token "votre_jeton_gitea_ici"
|
||||
```
|
||||
|
||||
**Option 3 — Tester à blanc avec `--dry-run` (Sans modifier Gitea) :**
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe scripts/publish_release.py --tag v2.0.0 --dry-run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Résultat Attendu dans Gitea
|
||||
|
||||
Une fois le script exécuté, visitez la page [ObsiGate Releases](https://git.dracodev.net/Projets/ObsiGate/releases).
|
||||
|
||||
Vous y trouverez la section de publication officielle contenant :
|
||||
- 📦 `ObsiGate_2.0.0_x64-setup.exe` (Installeur Windows)
|
||||
- 📦 `ObsiGate_2.0.0_x64_en-US.msi` (Package MSI Windows)
|
||||
- 📦 `ObsiGate_2.0.0_amd64.AppImage` (Executable autonome Linux)
|
||||
- 📦 `obsigate_2.0.0_amd64.deb` (Paquet Debian/Ubuntu)
|
||||
- 🔐 `checksums.txt` (Empreintes de sécurité SHA-256)
|
||||
- 📄 Code source `.zip` / `.tar.gz` (générés automatiquement)
|
||||
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ObsiGate — Script de publication des binaires Desktop vers Gitea Releases.
|
||||
|
||||
Ce script recherche les binaires compilés localement (Windows .exe/.msi, Linux .AppImage/.deb),
|
||||
génère le fichier checksums.txt (empreintes SHA256) et crée/met à jour la Release sur Gitea
|
||||
via son API REST (https://git.dracodev.net/api/v1).
|
||||
|
||||
Usage:
|
||||
python scripts/publish_release.py --tag v2.0.0
|
||||
python scripts/publish_release.py --tag v2.0.0 --dry-run
|
||||
python scripts/publish_release.py --tag v2.0.0 --token VOTRE_TOKEN_GITEA
|
||||
|
||||
Prérequis:
|
||||
Variable d'environnement GITEA_TOKEN ou jeton défini dans .env ou via --token.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
DEFAULT_GITEA_URL = "https://git.dracodev.net"
|
||||
DEFAULT_REPO = "Projets/ObsiGate"
|
||||
|
||||
|
||||
def load_env_token() -> str:
|
||||
"""Recherche GITEA_TOKEN dans les variables d'environnement ou dans le fichier .env."""
|
||||
token = os.environ.get("GITEA_TOKEN", "").strip()
|
||||
if token:
|
||||
return token
|
||||
|
||||
env_path = pathlib.Path(".env")
|
||||
if env_path.exists():
|
||||
with open(env_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line.startswith("GITEA_TOKEN="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def get_current_git_tag() -> str:
|
||||
"""Tente de lire la version depuis tauri.conf.json ou pyproject.toml si pas de tag fourni."""
|
||||
tauri_conf = pathlib.Path("desktop/tauri.conf.json")
|
||||
if tauri_conf.exists():
|
||||
try:
|
||||
with open(tauri_conf, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
ver = data.get("version")
|
||||
if ver:
|
||||
return f"v{ver}" if not ver.startswith("v") else ver
|
||||
except Exception:
|
||||
pass
|
||||
return "v2.0.0"
|
||||
|
||||
|
||||
def find_desktop_binaries(base_dir: pathlib.Path) -> list[pathlib.Path]:
|
||||
"""Recherche les binaires Desktop générés par Tauri dans le répertoire desktop/."""
|
||||
patterns = [
|
||||
"target/**/release/bundle/nsis/*.exe",
|
||||
"target/**/release/bundle/msi/*.msi",
|
||||
"target/**/release/bundle/appimage/*.AppImage",
|
||||
"target/**/release/bundle/deb/*.deb",
|
||||
"release_assets/*",
|
||||
]
|
||||
|
||||
found = []
|
||||
desktop_path = base_dir / "desktop" if (base_dir / "desktop").exists() else base_dir
|
||||
|
||||
for pattern in patterns:
|
||||
for p in desktop_path.glob(pattern):
|
||||
if p.is_file() and not p.name.endswith(".pdb"):
|
||||
found.append(p)
|
||||
|
||||
# Dédoublonner par nom de fichier (garder le plus récent si doublon)
|
||||
unique_files: dict[str, pathlib.Path] = {}
|
||||
for path in found:
|
||||
name = path.name
|
||||
if name not in unique_files or path.stat().st_mtime > unique_files[name].stat().st_mtime:
|
||||
unique_files[name] = path
|
||||
|
||||
return sorted(list(unique_files.values()), key=lambda x: x.name)
|
||||
|
||||
|
||||
def calculate_sha256(filepath: pathlib.Path) -> str:
|
||||
"""Calcule l'empreinte SHA-256 d'un fichier."""
|
||||
sha = hashlib.sha256()
|
||||
with open(filepath, "rb") as f:
|
||||
while chunk := f.read(65536):
|
||||
sha.update(chunk)
|
||||
return sha.hexdigest()
|
||||
|
||||
|
||||
def generate_checksums_file(files: list[pathlib.Path], output_path: pathlib.Path) -> dict[str, str]:
|
||||
"""Génère le fichier checksums.txt contenant SHA256 et noms de fichiers."""
|
||||
checksums = {}
|
||||
lines = []
|
||||
for f in files:
|
||||
digest = calculate_sha256(f)
|
||||
checksums[f.name] = digest
|
||||
lines.append(f"{digest} {f.name}")
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
return checksums
|
||||
|
||||
|
||||
def make_gitea_request(
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
token: str = "",
|
||||
data: bytes | None = None,
|
||||
content_type: str | None = None,
|
||||
) -> tuple[int, dict | str]:
|
||||
"""Effectue une requête HTTP vers l'API REST Gitea."""
|
||||
req = urllib.request.Request(url, method=method)
|
||||
if token:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
if content_type:
|
||||
req.add_header("Content-Type", content_type)
|
||||
req.add_header("Accept", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, data=data) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
status = resp.status
|
||||
try:
|
||||
return status, json.loads(body)
|
||||
except Exception:
|
||||
return status, body
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8")
|
||||
try:
|
||||
return e.code, json.loads(body)
|
||||
except Exception:
|
||||
return e.code, body
|
||||
|
||||
|
||||
def upload_asset_to_gitea(
|
||||
upload_url: str,
|
||||
file_path: pathlib.Path,
|
||||
token: str,
|
||||
) -> bool:
|
||||
"""Téléverse un fichier binaire vers l'API Gitea Release en Multipart Form."""
|
||||
boundary = "----WebKitFormBoundary" + hashlib.md5(file_path.name.encode()).hexdigest()[:16]
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
file_content = f.read()
|
||||
|
||||
filename = file_path.name
|
||||
content_type = "application/octet-stream"
|
||||
if filename.endswith(".exe"):
|
||||
content_type = "application/x-msdownload"
|
||||
elif filename.endswith(".msi"):
|
||||
content_type = "application/x-ole-storage"
|
||||
elif filename.endswith(".deb"):
|
||||
content_type = "application/vnd.debian.binary-package"
|
||||
elif filename.endswith(".txt"):
|
||||
content_type = "text/plain"
|
||||
|
||||
body = []
|
||||
body.append(f"--{boundary}".encode("utf-8"))
|
||||
body.append(f'Content-Disposition: form-data; name="attachment"; filename="{filename}"'.encode("utf-8"))
|
||||
body.append(f"Content-Type: {content_type}".encode("utf-8"))
|
||||
body.append(b"")
|
||||
body.append(file_content)
|
||||
body.append(f"--{boundary}--".encode("utf-8"))
|
||||
body.append(b"")
|
||||
|
||||
payload = b"\r\n".join(body)
|
||||
|
||||
status, resp = make_gitea_request(
|
||||
url=upload_url,
|
||||
method="POST",
|
||||
token=token,
|
||||
data=payload,
|
||||
content_type=f"multipart/form-data; boundary={boundary}",
|
||||
)
|
||||
|
||||
if status in (200, 201):
|
||||
print(f" ✅ Uploadé : {filename} ({len(file_content) / (1024*1024):.2f} MB)")
|
||||
return True
|
||||
else:
|
||||
print(f" ❌ Erreur upload pour {filename} (HTTP {status}) : {resp}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
parser = argparse.ArgumentParser(description="Publier des binaires Desktop sur Gitea Releases.")
|
||||
parser.add_argument("--tag", "-t", help="Tag de version (ex: v2.0.0)")
|
||||
parser.add_argument("--token", help="Jeton d'accès API Gitea (GITEA_TOKEN)")
|
||||
parser.add_argument("--gitea-url", default=DEFAULT_GITEA_URL, help=f"URL Gitea (défaut: {DEFAULT_GITEA_URL})")
|
||||
parser.add_argument("--repo", default=DEFAULT_REPO, help=f"Dépôt Gitea (défaut: {DEFAULT_REPO})")
|
||||
parser.add_argument("--notes", help="Notes de version (description textuelle)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Simuler sans envoyer à Gitea")
|
||||
parser.add_argument("--draft", action="store_true", help="Marquer la release comme brouillon")
|
||||
parser.add_argument("--prerelease", action="store_true", help="Marquer comme pré-release")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
tag = args.tag or get_current_git_tag()
|
||||
token = args.token or load_env_token()
|
||||
gitea_url = args.gitea_url.rstrip("/")
|
||||
repo = args.repo.strip("/")
|
||||
|
||||
print("==========================================================")
|
||||
print(f" ObsiGate — Publication Gitea Release ({tag})")
|
||||
print("==========================================================")
|
||||
print(f"📍 Serveur Gitea : {gitea_url}")
|
||||
print(f"📦 Dépôt : {repo}")
|
||||
print(f"🏷️ Tag : {tag}")
|
||||
print(f"🧪 Mode Dry-Run : {'OUI' if args.dry_run else 'NON'}")
|
||||
|
||||
# 1. Recherche des binaires
|
||||
root_dir = pathlib.Path(__file__).resolve().parent.parent
|
||||
binaries = find_desktop_binaries(root_dir)
|
||||
|
||||
if not binaries:
|
||||
print("\n⚠️ Aucun binaire Desktop trouvé dans desktop/target/.")
|
||||
print("💡 Pensez à exécuter au préalable :")
|
||||
print(" - Windows : .\\desktop\\build-windows.bat")
|
||||
print(" - Linux : ./desktop/build-linux.sh")
|
||||
else:
|
||||
print(f"\n🔍 {len(binaries)} binaire(s) trouvé(s) :")
|
||||
for b in binaries:
|
||||
size_mb = b.stat().st_size / (1024 * 1024)
|
||||
print(f" • {b.name} ({size_mb:.2f} MB) -> {b}")
|
||||
|
||||
# 2. Génération de checksums.txt
|
||||
checksums_path = root_dir / "desktop" / "checksums.txt"
|
||||
all_files_to_upload = list(binaries)
|
||||
|
||||
if binaries:
|
||||
print(f"\n🔐 Génération des empreintes SHA-256 ({checksums_path.name})...")
|
||||
generate_checksums_file(binaries, checksums_path)
|
||||
all_files_to_upload.append(checksums_path)
|
||||
print(" ✅ checksums.txt généré avec succès")
|
||||
|
||||
if args.dry_run:
|
||||
print("\n🧪 Mode Dry-Run terminé. Aucune action distante effectuée.")
|
||||
return 0
|
||||
|
||||
if not token:
|
||||
print("\n❌ ERREUR : Aucun jeton Gitea fourni.")
|
||||
print("Veuillez définir la variable GITEA_TOKEN ou passer --token VOTRE_TOKEN.")
|
||||
print("Pour générer un jeton sur Gitea : Mon Profil -> Paramètres -> Applications -> Générer un jeton")
|
||||
return 1
|
||||
|
||||
if not all_files_to_upload:
|
||||
print("\n❌ Aucun fichier à téléverser. Abandon.")
|
||||
return 1
|
||||
|
||||
# 3. Récupérer ou Créer la Release sur Gitea
|
||||
api_release_url = f"{gitea_url}/api/v1/repos/{repo}/releases"
|
||||
print(f"\n📡 Vérification de la release '{tag}' sur Gitea...")
|
||||
|
||||
status, releases = make_gitea_request(api_release_url, token=token)
|
||||
target_release = None
|
||||
|
||||
if status == 200 and isinstance(releases, list):
|
||||
for rel in releases:
|
||||
if rel.get("tag_name") == tag:
|
||||
target_release = rel
|
||||
break
|
||||
|
||||
release_body = args.notes or f"### ObsiGate {tag}\n\nPublication des binaires Desktop officiels (Windows & Linux)."
|
||||
|
||||
if target_release:
|
||||
release_id = target_release["id"]
|
||||
print(f"ℹ️ Release existante trouvée (ID: {release_id})")
|
||||
else:
|
||||
print(f"🆕 Création d'une nouvelle release pour le tag '{tag}'...")
|
||||
payload = json.dumps({
|
||||
"tag_name": tag,
|
||||
"target_commitish": "main",
|
||||
"name": f"ObsiGate {tag}",
|
||||
"body": release_body,
|
||||
"draft": args.draft,
|
||||
"prerelease": args.prerelease,
|
||||
}).encode("utf-8")
|
||||
|
||||
status, new_rel = make_gitea_request(
|
||||
api_release_url,
|
||||
method="POST",
|
||||
token=token,
|
||||
data=payload,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
if status in (200, 201) and isinstance(new_rel, dict):
|
||||
target_release = new_rel
|
||||
release_id = target_release["id"]
|
||||
print(f"✅ Release créée avec succès (ID: {release_id})")
|
||||
else:
|
||||
print(f"❌ Impossible de créer la release (HTTP {status}) : {new_rel}")
|
||||
return 1
|
||||
|
||||
# 4. Nettoyage des anciens assets portant le même nom si présents
|
||||
existing_assets = target_release.get("assets", [])
|
||||
upload_url = f"{gitea_url}/api/v1/repos/{repo}/releases/{release_id}/assets"
|
||||
|
||||
print(f"\n📤 Téléversement de {len(all_files_to_upload)} fichier(s) vers la release Gitea...")
|
||||
|
||||
for file_path in all_files_to_upload:
|
||||
# Supprimer si asset déjà existant
|
||||
for asset in existing_assets:
|
||||
if asset.get("name") == file_path.name:
|
||||
asset_id = asset["id"]
|
||||
del_url = f"{gitea_url}/api/v1/repos/{repo}/releases/{release_id}/assets/{asset_id}"
|
||||
make_gitea_request(del_url, method="DELETE", token=token)
|
||||
print(f" 🗑️ Ancien fichier {file_path.name} remplacé")
|
||||
break
|
||||
|
||||
upload_asset_to_gitea(upload_url, file_path, token)
|
||||
|
||||
print("\n==========================================================")
|
||||
print(f" 🎉 Publication terminée avec succès sur Gitea !")
|
||||
print(f" 🔗 {gitea_url}/{repo}/releases/tag/{tag}")
|
||||
print("==========================================================")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user