runv-server

server tooling for runv.club
Log | Files | Refs | README

genlanding.py (20989B)


      1 #!/usr/bin/env python3
      2 """
      3 Configura o Apache (Debian) para servir a landing runv.club: VirtualHost,
      4 mod_userdir + mod_rewrite, cópia de site/public para DocumentRoot, redirect
      5 www → apex em HTTP. Produção ou modo --dev para testes locais.
      6 Metadados SEO: editar site/public/. FAQ estático: public/faq/ (copiado com o resto).
      7 Notícias: site/news/publish_news.py gera public/news/data/news.json e feed.rss e, em produção,
      8 tenta ``genlanding --sync-public-only`` no fim (DocumentRoot existente). O MIME ``text/xml`` para
      9 ``/news/feed.rss`` fica em ``conf-available/runv-landing-rss-mime.conf`` (``a2enconf``), aplicável
     10 a :80 e :443 sem editar o vhost SSL do Certbot.
     11 
     12 Executar como root (excepto --dry-run). Apenas biblioteca padrão Python 3.
     13 
     14 Versão 0.08 — runv.club
     15 """
     16 
     17 from __future__ import annotations
     18 
     19 import argparse
     20 import grp
     21 import json
     22 import os
     23 import pwd
     24 import re
     25 import shutil
     26 import subprocess
     27 import sys
     28 import tempfile
     29 from pathlib import Path
     30 from typing import Final
     31 
     32 VERSION: Final[str] = "0.08"
     33 EXIT_OK: Final[int] = 0
     34 EXIT_USAGE: Final[int] = 1
     35 EXIT_ERROR: Final[int] = 2
     36 
     37 SCRIPT_DIR = Path(__file__).resolve().parent
     38 ADMIN_DIR = SCRIPT_DIR.parent / "scripts" / "admin"
     39 if str(ADMIN_DIR) not in sys.path:
     40     sys.path.insert(0, str(ADMIN_DIR))
     41 
     42 from admin_guard import ensure_admin_cli
     43 
     44 DEFAULT_SOURCE: Final[Path] = SCRIPT_DIR / "public"
     45 DEFAULT_MEMBERS_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json")
     46 
     47 PROD_DOMAIN: Final[str] = "runv.club"
     48 PROD_DOCUMENT_ROOT: Final[Path] = Path("/var/www/runv.club/html")
     49 PROD_SITE_CONF: Final[str] = "runv.club.conf"
     50 
     51 DEV_DOMAIN: Final[str] = "runv.local"
     52 DEV_DOCUMENT_ROOT: Final[Path] = Path("/var/www/runv-dev/html")
     53 DEV_SITE_CONF: Final[str] = "runv-dev.conf"
     54 
     55 APACHE_SITES_AVAILABLE: Final[Path] = Path("/etc/apache2/sites-available")
     56 APACHE_CONF_AVAILABLE: Final[Path] = Path("/etc/apache2/conf-available")
     57 # Snippet global: MIME do feed em todos os vhosts (:80 e :443), sem tocar no SSL do Certbot.
     58 RSS_MIME_CONF_FILE: Final[str] = "runv-landing-rss-mime.conf"
     59 RSS_MIME_CONF_STEM: Final[str] = "runv-landing-rss-mime"
     60 APACHE_CTL: Final[str] = "/usr/sbin/apache2ctl"
     61 DEFAULT_SITE: Final[str] = "000-default.conf"
     62 
     63 
     64 def eprint(msg: str) -> None:
     65     print(msg, file=sys.stderr)
     66 
     67 
     68 def require_root(*, dry_run: bool) -> None:
     69     if dry_run:
     70         return
     71     if os.geteuid() != 0:
     72         eprint("Erro: execute como root (sudo), excepto com --dry-run.")
     73         raise SystemExit(EXIT_USAGE)
     74 
     75 
     76 def apache_installed() -> bool:
     77     return Path(APACHE_CTL).is_file()
     78 
     79 
     80 def log_tag_from_domain(domain: str) -> str:
     81     """Nome seguro para ficheiros de log Apache."""
     82     return re.sub(r"[^\w.-]+", "-", domain).strip("-") or "runv"
     83 
     84 
     85 def render_rss_mime_conf_contents(document_root: Path) -> str:
     86     """Snippet em conf-available: vale para :80 e :443 (evita editar o vhost SSL do Certbot à mão)."""
     87     root = document_root.as_posix()
     88     return f"""# Gerado por genlanding.py v{VERSION} — runv.club
     89 # Chromium descarrega com application/rss+xml (mod_mime por extensão .rss).
     90 # RemoveType + Header forçam text/xml na resposta; requer mod_headers (a2enmod headers).
     91 # Global ao servidor para o caminho actual do DocumentRoot (volte a correr genlanding se mudar).
     92 
     93 <Directory {root}/news>
     94     RemoveType rss
     95     <Files "feed.rss">
     96         ForceType text/xml
     97         Header set Content-Type "text/xml; charset=utf-8"
     98     </Files>
     99 </Directory>
    100 """
    101 
    102 
    103 def render_vhost(
    104     *,
    105     server_name: str,
    106     document_root: Path,
    107     log_tag: str,
    108 ) -> str:
    109     www_alias = f"www.{server_name}"
    110     return f"""# Gerado por genlanding.py v{VERSION} — runv.club
    111 # Não editar à mão sem saber o que faz; volte a correr o script ou ajuste e recarregue o Apache.
    112 # MIME do feed RSS: conf-available/{RSS_MIME_CONF_FILE} (a2enconf {RSS_MIME_CONF_STEM}).
    113 
    114 <VirtualHost *:80>
    115     ServerName {server_name}
    116     ServerAlias {www_alias}
    117     DocumentRoot {document_root}
    118 
    119     # Redirect www → apex (HTTP; após Certbot o bloco :80 pode ser actualizado pelo certbot)
    120     RewriteEngine On
    121     RewriteCond %{{HTTP_HOST}} ^www\\.(.+)$ [NC]
    122     RewriteRule ^ http://%1%{{REQUEST_URI}} [R=301,L]
    123 
    124     <Directory {document_root}>
    125         Options FollowSymLinks
    126         AllowOverride None
    127         Require all granted
    128     </Directory>
    129 
    130     ErrorLog ${{APACHE_LOG_DIR}}/{log_tag}-error.log
    131     CustomLog ${{APACHE_LOG_DIR}}/{log_tag}-access.log combined
    132 </VirtualHost>
    133 """
    134 
    135 
    136 def run_cmd(
    137     cmd: list[str],
    138     *,
    139     dry_run: bool,
    140     verbose: bool = True,
    141 ) -> None:
    142     if verbose:
    143         print(f"  $ {' '.join(cmd)}")
    144     if dry_run:
    145         return
    146     r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
    147     if r.returncode != 0:
    148         err = (r.stderr or r.stdout or "").strip()
    149         raise RuntimeError(f"Comando falhou ({r.returncode}): {' '.join(cmd)}\n{err}")
    150 
    151 
    152 def run_cmd_allow_fail(
    153     cmd: list[str],
    154     *,
    155     dry_run: bool,
    156     ok_hint: str = "",
    157 ) -> None:
    158     print(f"  $ {' '.join(cmd)}")
    159     if dry_run:
    160         return
    161     r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
    162     if r.returncode == 0:
    163         print(f"  [ok] {' '.join(cmd)}")
    164     else:
    165         msg = (r.stderr or r.stdout or "").strip() or ok_hint
    166         print(f"  [info] {' '.join(cmd)} — {msg or 'ignorado (já inactivo?)'}")
    167 
    168 
    169 def copy_landing(source: Path, dest: Path, *, dry_run: bool) -> None:
    170     if not source.is_dir():
    171         raise FileNotFoundError(f"Pasta origem inexistente: {source}")
    172     if dry_run:
    173         print(f"  [dry-run] copiaria {source} -> {dest}")
    174         return
    175     dest.parent.mkdir(parents=True, exist_ok=True)
    176     if dest.exists():
    177         shutil.rmtree(dest)
    178     shutil.copytree(source, dest)
    179 
    180 
    181 def preserve_existing_members_json(document_root: Path, *, dry_run: bool) -> Path | None:
    182     """Guarda uma cópia temporária do members.json actual para rollback seguro da constelação."""
    183     current = document_root / "data" / "members.json"
    184     if dry_run or not current.is_file():
    185         return None
    186     fd, tmp_name = tempfile.mkstemp(prefix="runv-members-backup-", suffix=".json")
    187     os.close(fd)
    188     backup = Path(tmp_name)
    189     shutil.copy2(current, backup)
    190     return backup
    191 
    192 
    193 def restore_members_json_backup(
    194     document_root: Path,
    195     backup: Path | None,
    196     *,
    197     dry_run: bool,
    198 ) -> bool:
    199     """Restaura o members.json anterior se existir backup."""
    200     if dry_run or backup is None or not backup.is_file():
    201         return False
    202     out = document_root / "data" / "members.json"
    203     out.parent.mkdir(parents=True, exist_ok=True)
    204     shutil.copy2(backup, out)
    205     return True
    206 
    207 
    208 def cleanup_members_json_backup(backup: Path | None) -> None:
    209     if backup is None:
    210         return
    211     backup.unlink(missing_ok=True)
    212 
    213 
    214 def refresh_members_json_in_document_root(
    215     document_root: Path,
    216     *,
    217     users_json: Path,
    218     homes_root: Path | None,
    219     dry_run: bool,
    220 ) -> bool:
    221     """Regenera data/members.json no DocumentRoot após copiar site/public (stdlib)."""
    222     if dry_run:
    223         print(
    224             "  [dry-run] regeneraria data/members.json "
    225             f"({users_json} → {document_root / 'data' / 'members.json'})",
    226         )
    227         return True
    228     if not document_root.is_dir():
    229         eprint(
    230             f"Erro: DocumentRoot inexistente ({document_root}); não é possível gravar data/members.json."
    231         )
    232         return False
    233     script = SCRIPT_DIR / "build_directory.py"
    234     if not script.is_file():
    235         eprint(f"Aviso: {script} não encontrado; members.json não regenerado.")
    236         return False
    237     out = document_root / "data" / "members.json"
    238     cmd = [
    239         sys.executable,
    240         str(script),
    241         "--users-json",
    242         str(users_json),
    243         "-o",
    244         str(out),
    245     ]
    246     if homes_root is not None:
    247         cmd.extend(["--homes-root", str(homes_root)])
    248     print(f"  $ {' '.join(cmd)}")
    249     r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
    250     if r.returncode != 0:
    251         tail = (r.stderr or r.stdout or "").strip()
    252         eprint(
    253             f"Aviso: build_directory.py terminou com código {r.returncode}; "
    254             f"members.json pode estar desactualizado. {tail[:800]}"
    255         )
    256         return False
    257     else:
    258         print(f"  [ok] members.json em {out}")
    259         if r.stderr.strip():
    260             for line in r.stderr.strip().splitlines()[:5]:
    261                 print(f"      {line}")
    262         try:
    263             data = json.loads(out.read_text(encoding="utf-8"))
    264             if isinstance(data, list):
    265                 print(
    266                     f"  [ok] constelação (bolhas): {len(data)} membro(s) — "
    267                     "o index.html faz fetch a data/members.json (relativo ao DocumentRoot)."
    268                 )
    269             else:
    270                 eprint("Aviso: members.json não é uma lista JSON; verifique build_directory.py.")
    271                 return False
    272         except (OSError, json.JSONDecodeError, TypeError) as e:
    273             eprint(f"Aviso: não foi possível confirmar o conteúdo de members.json: {e}")
    274             return False
    275     return True
    276 
    277 
    278 def chown_www_data(path: Path, *, dry_run: bool) -> None:
    279     if dry_run:
    280         print(f"  [dry-run] chown -R www-data:www-data {path}")
    281         return
    282     try:
    283         u = pwd.getpwnam("www-data")
    284         g = grp.getgrnam("www-data")
    285     except KeyError as e:
    286         raise RuntimeError("Utilizador ou grupo 'www-data' não encontrado.") from e
    287     run_cmd(
    288         ["chown", "-R", f"{u.pw_uid}:{g.gr_gid}", str(path)],
    289         dry_run=False,
    290         verbose=True,
    291     )
    292 
    293 
    294 def parse_args(argv: list[str] | None) -> argparse.Namespace:
    295     p = argparse.ArgumentParser(
    296         description="Configura Apache para a landing runv (VirtualHost, userdir, cópia de public/).",
    297     )
    298     p.add_argument(
    299         "--source",
    300         type=Path,
    301         default=DEFAULT_SOURCE,
    302         help=f"pasta com a landing (default: {DEFAULT_SOURCE})",
    303     )
    304     p.add_argument(
    305         "--document-root",
    306         type=Path,
    307         default=None,
    308         help="DocumentRoot do VirtualHost (default: prod ou dev conforme --dev)",
    309     )
    310     p.add_argument(
    311         "--domain",
    312         type=str,
    313         default=None,
    314         help="ServerName (default: runv.club ou runv.local com --dev)",
    315     )
    316     p.add_argument(
    317         "--dev",
    318         action="store_true",
    319         help="modo teste local: runv.local, runv-dev.conf, não desactiva 000-default",
    320     )
    321     p.add_argument("--dry-run", action="store_true", help="mostra acções sem alterar o sistema")
    322     p.add_argument(
    323         "--certbot",
    324         action="store_true",
    325         help="executa certbot --apache após configurar HTTP (incompatível com --dev)",
    326     )
    327     p.add_argument(
    328         "--keep-default-site",
    329         action="store_true",
    330         help="não desactiva 000-default.conf (produção e --dev: mantém página Debian; pedidos por IP não casam com ServerName)",
    331     )
    332     p.add_argument(
    333         "--sync-public-only",
    334         action="store_true",
    335         help=(
    336             "só copia site/public → DocumentRoot, chown www-data e regenera data/members.json; "
    337             "não configura Apache nem recarrega o serviço (uso típico: após create_runv_user.py)"
    338         ),
    339     )
    340     p.add_argument(
    341         "--no-refresh-members",
    342         action="store_true",
    343         help="não executar site/build_directory.py após copiar public/ (omitir data/members.json)",
    344     )
    345     p.add_argument(
    346         "--members-users-json",
    347         type=Path,
    348         default=DEFAULT_MEMBERS_USERS_JSON,
    349         help=f"fonte para build_directory.py (default: {DEFAULT_MEMBERS_USERS_JSON})",
    350     )
    351     p.add_argument(
    352         "--members-homes-root",
    353         type=Path,
    354         default=None,
    355         help="opcional: --homes-root para build_directory.py (ex. /home)",
    356     )
    357     p.add_argument("--version", action="version", version=f"%(prog)s {VERSION} — runv.club")
    358     return p.parse_args(argv)
    359 
    360 
    361 def resolve_profile(args: argparse.Namespace) -> tuple[str, Path, str, bool]:
    362     """
    363     Retorna (domain, document_root, site_conf_filename, disable_default_site).
    364     """
    365     if args.dev:
    366         domain = (args.domain or DEV_DOMAIN).strip().lower()
    367         doc = args.document_root or DEV_DOCUMENT_ROOT
    368         conf = DEV_SITE_CONF
    369     else:
    370         domain = (args.domain or PROD_DOMAIN).strip().lower()
    371         doc = args.document_root or PROD_DOCUMENT_ROOT
    372         conf = PROD_SITE_CONF
    373     # Mesma regra em prod e --dev: sem --keep-default-site, desactiva 000-default para que
    374     # pedidos por IP (Host sem match) caiam no vhost runv em vez da página Debian.
    375     disable_default = not args.keep_default_site
    376     return domain, doc.resolve(), conf, disable_default
    377 
    378 
    379 def sync_public_only_main(args: argparse.Namespace) -> int:
    380     """Copia site/public → DocumentRoot, chown e members.json; sem Apache."""
    381     _, document_root, _, _ = resolve_profile(args)
    382     source = args.source.resolve()
    383 
    384     print(f"== genlanding.py v{VERSION} — sync-public-only ==")
    385     print(f"  modo: {'dev' if args.dev else 'produção'}")
    386     print(f"  DocumentRoot: {document_root}")
    387     print(f"  origem: {source}")
    388     print()
    389 
    390     members_backup = preserve_existing_members_json(document_root, dry_run=args.dry_run)
    391     try:
    392         copy_landing(source, document_root, dry_run=args.dry_run)
    393         if not args.dry_run:
    394             chown_www_data(document_root, dry_run=False)
    395 
    396         if not args.no_refresh_members:
    397             refreshed = refresh_members_json_in_document_root(
    398                 document_root,
    399                 users_json=args.members_users_json,
    400                 homes_root=args.members_homes_root.resolve()
    401                 if args.members_homes_root
    402                 else None,
    403                 dry_run=args.dry_run,
    404             )
    405             if not refreshed and restore_members_json_backup(
    406                 document_root,
    407                 members_backup,
    408                 dry_run=args.dry_run,
    409             ):
    410                 print("  [ok] members.json anterior restaurado; constelação preservada.")
    411         elif restore_members_json_backup(document_root, members_backup, dry_run=args.dry_run):
    412             print("  [ok] members.json anterior preservado (--no-refresh-members).")
    413     except (FileNotFoundError, OSError, RuntimeError) as e:
    414         eprint(f"Erro: {e}")
    415         cleanup_members_json_backup(members_backup)
    416         return EXIT_ERROR
    417     cleanup_members_json_backup(members_backup)
    418 
    419     print()
    420     print("  [ok] sync-public-only concluído (Apache não foi alterado).")
    421     return EXIT_OK
    422 
    423 
    424 def main(argv: list[str] | None = None) -> int:
    425     args = parse_args(argv)
    426     ensure_admin_cli(
    427         script_name=Path(__file__).name,
    428         dry_run=bool(args.dry_run),
    429     )
    430 
    431     if args.dev and args.certbot:
    432         eprint("Erro: --certbot não pode ser usado com --dev (Certbot não serve para domínios locais).")
    433         return EXIT_USAGE
    434 
    435     if args.sync_public_only and args.certbot:
    436         eprint("Erro: --certbot não pode ser usado com --sync-public-only.")
    437         return EXIT_USAGE
    438 
    439     require_root(dry_run=args.dry_run)
    440 
    441     if args.sync_public_only:
    442         return sync_public_only_main(args)
    443 
    444     domain, document_root, site_conf_name, disable_default = resolve_profile(args)
    445     source = args.source.resolve()
    446     conf_path = APACHE_SITES_AVAILABLE / site_conf_name
    447     log_tag = log_tag_from_domain(domain)
    448 
    449     print(f"== genlanding.py v{VERSION} — runv.club ==")
    450     print(f"  modo: {'dev' if args.dev else 'produção'}")
    451     print(f"  ServerName: {domain}")
    452     print(f"  DocumentRoot: {document_root}")
    453     print(f"  ficheiro site: {conf_path}")
    454     print(f"  origem: {source}")
    455     print()
    456 
    457     members_backup = preserve_existing_members_json(document_root, dry_run=args.dry_run)
    458     if not apache_installed():
    459         eprint("Erro: Apache não parece instalado (falta /usr/sbin/apache2ctl).")
    460         eprint("       Instale com: sudo apt install -y apache2")
    461         eprint("       ou corra scripts/admin/starthere.py antes.")
    462         cleanup_members_json_backup(members_backup)
    463         return EXIT_ERROR
    464 
    465     vhost_body = render_vhost(
    466         server_name=domain,
    467         document_root=document_root,
    468         log_tag=log_tag,
    469     )
    470 
    471     try:
    472         if args.dry_run:
    473             print("--- VirtualHost (pré-visualização) ---")
    474             print(vhost_body)
    475 
    476         run_cmd(["a2enmod", "userdir"], dry_run=args.dry_run)
    477         run_cmd(["a2enmod", "rewrite"], dry_run=args.dry_run)
    478         run_cmd(["a2enmod", "headers"], dry_run=args.dry_run)
    479 
    480         rss_conf_path = APACHE_CONF_AVAILABLE / RSS_MIME_CONF_FILE
    481         rss_body = render_rss_mime_conf_contents(document_root)
    482         if args.dry_run:
    483             print("--- conf-available (RSS MIME, :80 e :443) ---")
    484             print(rss_body)
    485             print(f"  [dry-run] escreveria {rss_conf_path} ; a2enconf {RSS_MIME_CONF_STEM}")
    486         else:
    487             if not APACHE_CONF_AVAILABLE.is_dir():
    488                 APACHE_CONF_AVAILABLE.mkdir(parents=True, exist_ok=True)
    489             rss_conf_path.write_text(rss_body, encoding="utf-8")
    490             os.chmod(rss_conf_path, 0o644)
    491             print(f"  [ok] RSS MIME: {rss_conf_path}")
    492         run_cmd_allow_fail(
    493             ["a2enconf", RSS_MIME_CONF_STEM],
    494             dry_run=args.dry_run,
    495             ok_hint="conf já activo",
    496         )
    497 
    498         copy_landing(source, document_root, dry_run=args.dry_run)
    499         if not args.dry_run:
    500             chown_www_data(document_root, dry_run=False)
    501 
    502         if not args.no_refresh_members:
    503             refreshed = refresh_members_json_in_document_root(
    504                 document_root,
    505                 users_json=args.members_users_json,
    506                 homes_root=args.members_homes_root.resolve()
    507                 if args.members_homes_root
    508                 else None,
    509                 dry_run=args.dry_run,
    510             )
    511             if not refreshed and restore_members_json_backup(
    512                 document_root,
    513                 members_backup,
    514                 dry_run=args.dry_run,
    515             ):
    516                 print("  [ok] members.json anterior restaurado; constelação preservada.")
    517         elif restore_members_json_backup(document_root, members_backup, dry_run=args.dry_run):
    518             print("  [ok] members.json anterior preservado (--no-refresh-members).")
    519 
    520         if args.dry_run:
    521             print(f"  [dry-run] escreveria {conf_path}")
    522         else:
    523             conf_path.write_text(vhost_body, encoding="utf-8")
    524             os.chmod(conf_path, 0o644)
    525         print(f"  [ok] VirtualHost em {conf_path}")
    526 
    527         if disable_default:
    528             run_cmd_allow_fail(
    529                 ["a2dissite", DEFAULT_SITE],
    530                 dry_run=args.dry_run,
    531                 ok_hint="site por defeito já estava desactivado",
    532             )
    533         else:
    534             print("  [info] site por defeito 000-default mantido activo.")
    535 
    536         run_cmd(["a2ensite", site_conf_name], dry_run=args.dry_run)
    537 
    538         run_cmd([APACHE_CTL, "configtest"], dry_run=args.dry_run)
    539 
    540         if not args.dry_run:
    541             subprocess.run(
    542                 ["systemctl", "reload", "apache2"],
    543                 check=True,
    544                 timeout=60,
    545             )
    546         else:
    547             print("  [dry-run] systemctl reload apache2")
    548         print("  [ok] Apache recarregado.")
    549 
    550         if args.certbot:
    551             www = f"www.{domain}"
    552             print()
    553             certbot_bin = shutil.which("certbot")
    554             if not certbot_bin:
    555                 eprint("Erro: certbot não encontrado no PATH. Instale: sudo apt install -y certbot python3-certbot-apache")
    556                 return EXIT_ERROR
    557             print("  A executar Certbot (interactivo se necessário)...")
    558             if args.dry_run:
    559                 print(f"  [dry-run] {certbot_bin} --apache -d {domain} -d {www}")
    560             else:
    561                 r = subprocess.run(
    562                     [certbot_bin, "--apache", "-d", domain, "-d", www],
    563                     check=False,
    564                 )
    565                 if r.returncode != 0:
    566                     eprint("Aviso: certbot terminou com código != 0; verifique TLS manualmente.")
    567                     return EXIT_ERROR
    568                 print("  [ok] Certbot concluído.")
    569 
    570     except (FileNotFoundError, OSError, RuntimeError) as e:
    571         eprint(f"Erro: {e}")
    572         cleanup_members_json_backup(members_backup)
    573         return EXIT_ERROR
    574     cleanup_members_json_backup(members_backup)
    575 
    576     print()
    577     print("Próximos passos:")
    578     print(f"  - Testar: curl -sI http://{domain}/ | head -5")
    579     if args.dev:
    580         print("  - Em /etc/hosts (cliente ou VM): 127.0.0.1  runv.local  www.runv.local")
    581     print(
    582         "  - Membros na constelação: regenerado com build_directory após esta cópia "
    583         "(fonte: /var/lib/runv/users.json). Novas contas: create_runv_user.py corre "
    584         "genlanding.py --sync-public-only (public + members). Use --no-refresh-members para omitir."
    585     )
    586     return EXIT_OK
    587 
    588 
    589 if __name__ == "__main__":
    590     raise SystemExit(main())