浏览代码

DNS Manager

root 6 月之前
当前提交
675bb1279f
共有 100 个文件被更改,包括 15930 次插入 和 0 次删除
  1. 二进制
      __pycache__/config.cpython-39.pyc
  2. 1029 0
      app.py
  3. 97 0
      config.py
  4. 0 0
      core/__init__.py
  5. 二进制
      core/__pycache__/__init__.cpython-39.pyc
  6. 二进制
      core/__pycache__/adblock_manager.cpython-39.pyc
  7. 二进制
      core/__pycache__/cluster_manager.cpython-39.pyc
  8. 二进制
      core/__pycache__/dns_manager.cpython-39.pyc
  9. 二进制
      core/__pycache__/file_manager.cpython-39.pyc
  10. 二进制
      core/__pycache__/stats_manager.cpython-39.pyc
  11. 127 0
      core/adblock_manager.py
  12. 50 0
      core/cluster_manager.py
  13. 139 0
      core/dns_manager.py
  14. 104 0
      core/file_manager.py
  15. 93 0
      core/stats_manager.py
  16. 146 0
      templates/adanalytics.html
  17. 435 0
      templates/adblock.html
  18. 219 0
      templates/analytics.html
  19. 214 0
      templates/cluster.html
  20. 354 0
      templates/dashboard.html
  21. 14 0
      templates/editor.html
  22. 294 0
      templates/general_config.html
  23. 108 0
      templates/layout.html
  24. 23 0
      templates/login.html
  25. 583 0
      templates/manage_zone.html
  26. 99 0
      templates/users.html
  27. 57 0
      templates/zones.html
  28. 241 0
      venv/bin/Activate.ps1
  29. 66 0
      venv/bin/activate
  30. 25 0
      venv/bin/activate.csh
  31. 64 0
      venv/bin/activate.fish
  32. 8 0
      venv/bin/easy_install
  33. 8 0
      venv/bin/easy_install-3.9
  34. 6 0
      venv/bin/flask
  35. 8 0
      venv/bin/pip
  36. 8 0
      venv/bin/pip3
  37. 8 0
      venv/bin/pip3.9
  38. 1 0
      venv/bin/python
  39. 1 0
      venv/bin/python3
  40. 1 0
      venv/bin/python3.9
  41. 二进制
      venv/lib/python3.9/site-packages/__pycache__/easy_install.cpython-39.pyc
  42. 1 0
      venv/lib/python3.9/site-packages/blinker-1.9.0.dist-info/INSTALLER
  43. 20 0
      venv/lib/python3.9/site-packages/blinker-1.9.0.dist-info/LICENSE.txt
  44. 60 0
      venv/lib/python3.9/site-packages/blinker-1.9.0.dist-info/METADATA
  45. 12 0
      venv/lib/python3.9/site-packages/blinker-1.9.0.dist-info/RECORD
  46. 4 0
      venv/lib/python3.9/site-packages/blinker-1.9.0.dist-info/WHEEL
  47. 17 0
      venv/lib/python3.9/site-packages/blinker/__init__.py
  48. 二进制
      venv/lib/python3.9/site-packages/blinker/__pycache__/__init__.cpython-39.pyc
  49. 二进制
      venv/lib/python3.9/site-packages/blinker/__pycache__/_utilities.cpython-39.pyc
  50. 二进制
      venv/lib/python3.9/site-packages/blinker/__pycache__/base.cpython-39.pyc
  51. 64 0
      venv/lib/python3.9/site-packages/blinker/_utilities.py
  52. 512 0
      venv/lib/python3.9/site-packages/blinker/base.py
  53. 0 0
      venv/lib/python3.9/site-packages/blinker/py.typed
  54. 1 0
      venv/lib/python3.9/site-packages/click-8.1.8.dist-info/INSTALLER
  55. 28 0
      venv/lib/python3.9/site-packages/click-8.1.8.dist-info/LICENSE.txt
  56. 74 0
      venv/lib/python3.9/site-packages/click-8.1.8.dist-info/METADATA
  57. 38 0
      venv/lib/python3.9/site-packages/click-8.1.8.dist-info/RECORD
  58. 4 0
      venv/lib/python3.9/site-packages/click-8.1.8.dist-info/WHEEL
  59. 75 0
      venv/lib/python3.9/site-packages/click/__init__.py
  60. 二进制
      venv/lib/python3.9/site-packages/click/__pycache__/__init__.cpython-39.pyc
  61. 二进制
      venv/lib/python3.9/site-packages/click/__pycache__/_compat.cpython-39.pyc
  62. 二进制
      venv/lib/python3.9/site-packages/click/__pycache__/_termui_impl.cpython-39.pyc
  63. 二进制
      venv/lib/python3.9/site-packages/click/__pycache__/_textwrap.cpython-39.pyc
  64. 二进制
      venv/lib/python3.9/site-packages/click/__pycache__/_winconsole.cpython-39.pyc
  65. 二进制
      venv/lib/python3.9/site-packages/click/__pycache__/core.cpython-39.pyc
  66. 二进制
      venv/lib/python3.9/site-packages/click/__pycache__/decorators.cpython-39.pyc
  67. 二进制
      venv/lib/python3.9/site-packages/click/__pycache__/exceptions.cpython-39.pyc
  68. 二进制
      venv/lib/python3.9/site-packages/click/__pycache__/formatting.cpython-39.pyc
  69. 二进制
      venv/lib/python3.9/site-packages/click/__pycache__/globals.cpython-39.pyc
  70. 二进制
      venv/lib/python3.9/site-packages/click/__pycache__/parser.cpython-39.pyc
  71. 二进制
      venv/lib/python3.9/site-packages/click/__pycache__/shell_completion.cpython-39.pyc
  72. 二进制
      venv/lib/python3.9/site-packages/click/__pycache__/termui.cpython-39.pyc
  73. 二进制
      venv/lib/python3.9/site-packages/click/__pycache__/testing.cpython-39.pyc
  74. 二进制
      venv/lib/python3.9/site-packages/click/__pycache__/types.cpython-39.pyc
  75. 二进制
      venv/lib/python3.9/site-packages/click/__pycache__/utils.cpython-39.pyc
  76. 623 0
      venv/lib/python3.9/site-packages/click/_compat.py
  77. 788 0
      venv/lib/python3.9/site-packages/click/_termui_impl.py
  78. 49 0
      venv/lib/python3.9/site-packages/click/_textwrap.py
  79. 279 0
      venv/lib/python3.9/site-packages/click/_winconsole.py
  80. 3047 0
      venv/lib/python3.9/site-packages/click/core.py
  81. 562 0
      venv/lib/python3.9/site-packages/click/decorators.py
  82. 296 0
      venv/lib/python3.9/site-packages/click/exceptions.py
  83. 301 0
      venv/lib/python3.9/site-packages/click/formatting.py
  84. 67 0
      venv/lib/python3.9/site-packages/click/globals.py
  85. 531 0
      venv/lib/python3.9/site-packages/click/parser.py
  86. 0 0
      venv/lib/python3.9/site-packages/click/py.typed
  87. 603 0
      venv/lib/python3.9/site-packages/click/shell_completion.py
  88. 784 0
      venv/lib/python3.9/site-packages/click/termui.py
  89. 483 0
      venv/lib/python3.9/site-packages/click/testing.py
  90. 1093 0
      venv/lib/python3.9/site-packages/click/types.py
  91. 624 0
      venv/lib/python3.9/site-packages/click/utils.py
  92. 5 0
      venv/lib/python3.9/site-packages/easy_install.py
  93. 1 0
      venv/lib/python3.9/site-packages/flask-3.0.3.dist-info/INSTALLER
  94. 28 0
      venv/lib/python3.9/site-packages/flask-3.0.3.dist-info/LICENSE.txt
  95. 101 0
      venv/lib/python3.9/site-packages/flask-3.0.3.dist-info/METADATA
  96. 58 0
      venv/lib/python3.9/site-packages/flask-3.0.3.dist-info/RECORD
  97. 0 0
      venv/lib/python3.9/site-packages/flask-3.0.3.dist-info/REQUESTED
  98. 4 0
      venv/lib/python3.9/site-packages/flask-3.0.3.dist-info/WHEEL
  99. 3 0
      venv/lib/python3.9/site-packages/flask-3.0.3.dist-info/entry_points.txt
  100. 60 0
      venv/lib/python3.9/site-packages/flask/__init__.py

二进制
__pycache__/config.cpython-39.pyc


+ 1029 - 0
app.py

@@ -0,0 +1,1029 @@
+from flask import Flask, render_template, request, redirect, flash, url_for, jsonify, send_file, session
+from werkzeug.security import check_password_hash, generate_password_hash
+from functools import wraps
+import subprocess, os, re, time, json, zipfile, io, datetime, urllib.request
+
+import config
+from core import file_manager as fm
+from core import dns_manager as dns
+from core import adblock_manager as ab
+from core import cluster_manager as cm
+from core import stats_manager as stats
+
+app = Flask(__name__)
+app.secret_key = "bind_pro_v108_modular_masterpiece"
+
+# Εκκίνηση του Background Daemon για τα Analytics
+stats.start_stats_daemon()
+
+# --- DECORATORS & AUTH ---
+def login_required(f):
+    @wraps(f)
+    def dec(*args, **kwargs):
+        if 'username' not in session: return redirect(url_for('login'))
+        return f(*args, **kwargs)
+    return dec
+
+def admin_required(f):
+    @wraps(f)
+    def dec(*args, **kwargs):
+        if session.get('role') != 'admin': return redirect(url_for('dashboard'))
+        return f(*args, **kwargs)
+    return dec
+
+def api_required(f):
+    @wraps(f)
+    def dec(*args, **kwargs):
+        key = request.headers.get('X-API-Key')
+        if not key or key != fm.load_panel_settings().get('api_key'): 
+            return jsonify({"status": "error", "message": "Unauthorized"}), 401
+        return f(*args, **kwargs)
+    return dec
+
+def check_zone_access(domain):
+    if session.get('role') == 'admin': return True
+    return fm.load_panel_settings().get('zone_owners', {}).get(domain) == session.get('username')
+
+# --- AUTH ROUTES ---
+@app.route('/login', methods=['GET', 'POST'])
+def login():
+    if request.method == 'POST':
+        u, p = request.form.get('username'), request.form.get('password')
+        users = fm.load_users()
+        if u in users and check_password_hash(users[u]['password'], p):
+            session.update({'username': u, 'role': users[u]['role']})
+            return redirect(url_for('dashboard'))
+        flash("Invalid Credentials", "error")
+    return render_template('login.html')
+
+@app.route('/logout')
+def logout():
+    session.clear()
+    return redirect(url_for('login'))
+
+# --- MAIN DASHBOARD & ZONES ---
+@app.route('/')
+@login_required
+def dashboard():
+    conf = fm.read_file(config.LOCAL_CONF)
+    zones_raw = re.findall(r'zone\s+"([^"]+)"\s*\{.*?type\s+(master|slave|forward|hint);', conf, re.DOTALL)
+    zones_raw = [z for z in zones_raw if z[0] != "rpz.blacklist"]
+    
+    settings = fm.load_panel_settings()
+    owners, users = settings.get('zone_owners', {}), fm.load_users()
+    
+    if session['role'] == 'admin': 
+        zones = zones_raw
+    else: 
+        zones = [z for z in zones_raw if owners.get(z[0]) == session['username']]
+        
+    try: status = subprocess.check_output(["systemctl", "is-active", config.BIND_SERVICE]).decode().strip()
+    except: status = "inactive"
+    
+    view_mode = users.get(session['username'], {}).get('view_mode', 'cards')
+    return render_template('dashboard.html', zones=zones, status=status, templates=settings['templates'], 
+                           owners=owners, all_usernames=list(users.keys()), view_mode=view_mode)
+
+@app.route('/zones/add', methods=['POST'])
+@login_required
+def add_zone():
+    raw_domain = request.form.get('domain', '').strip()
+    domain = re.sub(r'[^a-zA-Z0-9\.\-\/]', '', raw_domain).replace('../', '') 
+    z_type, direction = request.form.get('type'), request.form.get('direction', 'forward')
+    
+    if z_type != "hint" and direction == 'reverse' and not domain.endswith('.arpa'):
+        domain = re.sub(r'/.*$', '', domain)
+        parts = domain.strip('.').split('.')
+        if len(parts) == 4 and parts[3] == '0': parts = parts[:3]
+        domain = ".".join(reversed(parts)) + ".in-addr.arpa"
+        
+    if z_type == "hint": domain = "."
+    conf = fm.read_file(config.LOCAL_CONF)
+    default_zones = fm.read_file(os.path.join(config.BIND_PATH, "named.conf.default-zones"))
+    
+    if f'zone "{domain}"' in conf or f'zone "{domain}"' in default_zones: 
+        flash(f"Zone '{domain}' exists!", "error")
+        return redirect(url_for('dashboard'))
+
+    if z_type == "hint":
+        hint_path = os.path.join(config.BIND_PATH, "db.root")
+        try: 
+            urllib.request.urlretrieve("https://www.internic.net/domain/named.root", hint_path)
+            subprocess.run(["chown", "bind:bind", hint_path])
+        except: 
+            fm.write_file(hint_path, ". 3600000 IN NS A.ROOT-SERVERS.NET.\nA.ROOT-SERVERS.NET. 3600000 A 198.41.0.4")
+        conf += f'\nzone "." {{\n    type hint;\n    file "{hint_path}";\n}};\n'
+        
+    elif z_type == "master":
+        path = os.path.join(config.BIND_PATH, f"db.{domain}")
+        conf += f'\nzone "{domain}" {{\n    type master;\n    file "{path}";\n}};\n'
+        t_name = request.form.get('template_name')
+        if t_name:
+            t = fm.load_panel_settings()['templates'].get(t_name)
+            if t: fm.write_file(path, dns.apply_template(t['content'], domain, t))
+        else:
+            s_ip = fm.load_panel_settings().get('server_public_ip', '') or '127.0.0.1'
+            fm.write_file(path, f"$TTL 86400\n@ IN SOA ns1.{domain}. admin.{domain}. (\n {time.strftime('%Y%m%d01')}\n 3600\n 1800\n 604800\n 86400\n)\n@ IN NS ns1.{domain}.\nns1 IN A {s_ip}\n")
+            
+    elif z_type == "forward":
+        ips = "".join([f" {x.strip()};" for x in request.form.getlist('forwarders[]') if x.strip()])
+        conf += f'\nzone "{domain}" {{\n    type forward;\n    forwarders {{{ips} }};\n}};\n'
+        
+    else:
+        path = os.path.join(config.CACHE_PATH, f"db.{domain}")
+        ips = "".join([f" {x.strip()};" for x in request.form.getlist('master_ips[]') if x.strip()])
+        conf += f'\nzone "{domain}" {{\n    type slave;\n    file "{path}";\n    masters {{{ips} }};\n    masterfile-format text;\n}};\n'
+
+    fm.write_file(config.LOCAL_CONF, conf)
+    s = fm.load_panel_settings()
+    s['zone_owners'][domain] = session['username']
+    fm.save_panel_settings(s)
+    subprocess.run(["rndc", "reconfig"])
+    
+    if z_type == "master": cm.broadcast_to_cluster('add_slave', domain)
+    flash(f"Zone '{domain}' created!", "success")
+    return redirect(url_for('dashboard'))
+
+@app.route('/zones/manage/<path:domain>')
+@login_required
+def manage_zone(domain):
+    clean = "." if domain == "root_zone" else domain.strip('/')
+    if not check_zone_access(clean): return redirect(url_for('dashboard'))
+    
+    conf = fm.read_file(config.LOCAL_CONF)
+    block = dns.get_zone_block(conf, clean)
+    if not block: return redirect(url_for('dashboard'))
+    
+    path = dns.get_zone_file_path(clean)
+    try:
+        cmd = f"tail -n 5000 {config.QUERY_LOG_PATH} | grep -iE '({clean}|transfer|notify|axfr)' | tail -n 30"
+        logs = subprocess.check_output(cmd, shell=True, text=True).strip().split('\n')
+    except: logs = []
+    
+    records = dns.parse_zone_file(path, clean)
+    has_records = any(len(r)>0 for r in records.values())
+    
+    m_ips = (re.search(r'masters\s*\{\s*(.*?)\s*\};', block, re.S | re.I) or type('m',(),{'group':lambda x,y:''})()).group(1).replace(';',' ')
+    f_ips = (re.search(r'forwarders\s*\{\s*(.*?)\s*\};', block, re.S | re.I) or type('m',(),{'group':lambda x,y:''})()).group(1).replace(';',' ')
+    
+    z_type = "hint" if "type hint" in block else "slave" if "type slave" in block else "forward" if "type forward" in block else "master"
+    
+    audit_logs = fm.get_audit_log(clean)
+    backups = dns.get_backups(clean)
+    dnssec_enabled = "dnssec-policy default" in block
+    ds_records = dns.get_ds_records(clean) if dnssec_enabled else []
+    
+    return render_template('manage_zone.html', domain=clean, type=z_type, records=records, has_records=has_records, 
+                           path=path, logs=[l for l in logs if l], master_ips=m_ips, forwarders=f_ips, 
+                           audit_logs=audit_logs, backups=backups, dnssec_enabled=dnssec_enabled, ds_records=ds_records)
+
+@app.route('/zones/delete/<path:domain>')
+@login_required
+def delete_zone(domain):
+    clean = "." if domain == "root_zone" else domain.strip('/')
+    if not check_zone_access(clean): return redirect(url_for('dashboard'))
+    
+    conf = fm.read_file(config.LOCAL_CONF)
+    path = dns.get_zone_file_path(clean)
+    block = dns.get_zone_block(conf, clean)
+    
+    if block:
+        fm.write_file(config.LOCAL_CONF, conf.replace(block, ""))
+        s = fm.load_panel_settings()
+        s['zone_owners'].pop(clean, None)
+        fm.save_panel_settings(s)
+        subprocess.run(["rndc", "reconfig"])
+        try: os.remove(path)
+        except: pass
+        if "type master" in block: cm.broadcast_to_cluster('delete_zone', clean)
+        
+    flash("Zone Deleted", "success")
+    return redirect(url_for('dashboard'))
+
+# --- ZONE ACTIONS (RECORDS, IP, DNSSEC) ---
+@app.route('/zones/manage/<path:domain>/add_record', methods=['POST'])
+@login_required
+def add_record(domain):
+    clean = "." if domain == "root_zone" else domain
+    if not check_zone_access(clean): return redirect(url_for('dashboard'))
+    path, rtype = dns.get_zone_file_path(clean), request.form.get('rtype')
+    name = dns.sanitize_record_name(request.form.get('name'), clean)
+    val = dns.construct_record_value(request.form, rtype)
+    
+    new_content = dns.increment_serial(fm.read_file(path)) + f"{name}\tIN\t{rtype}\t{val}\n"
+    is_valid, err = dns.check_zone_syntax(clean, new_content)
+    if not is_valid: 
+        flash(f"Linter Error: {err}", "error")
+        return redirect(url_for('manage_zone', domain=domain))
+        
+    dns.backup_zone(clean, fm.read_file(path))
+    fm.write_file(path, new_content)
+    subprocess.run(["rndc", "reload", clean])
+    fm.log_audit(session['username'], clean, f"Added record {rtype} {name}")
+    flash("Record added", "success")
+    return redirect(url_for('manage_zone', domain=domain))
+
+@app.route('/zones/manage/<path:domain>/delete_record', methods=['POST'])
+@login_required
+def delete_record(domain):
+    clean = "." if domain == "root_zone" else domain
+    if not check_zone_access(clean): return redirect(url_for('dashboard'))
+    path, raw = dns.get_zone_file_path(clean), request.form.get('raw_line').strip()
+    lines = fm.read_file(path).split('\n')
+    
+    for i, l in enumerate(lines):
+        if l.strip() == raw: 
+            del lines[i]
+            break
+            
+    new_content = dns.increment_serial('\n'.join(lines))
+    is_valid, err = dns.check_zone_syntax(clean, new_content)
+    if not is_valid: 
+        flash(f"Linter Error: {err}", "error")
+        return redirect(url_for('manage_zone', domain=domain))
+        
+    dns.backup_zone(clean, fm.read_file(path))
+    fm.write_file(path, new_content)
+    subprocess.run(["rndc", "reload", clean])
+    fm.log_audit(session['username'], clean, "Deleted a record")
+    flash("Record deleted", "success")
+    return redirect(url_for('manage_zone', domain=domain))
+
+@app.route('/zones/manage/<path:domain>/edit_record', methods=['POST'])
+@login_required
+def edit_record(domain):
+    clean = "." if domain == "root_zone" else domain
+    if not check_zone_access(clean): return redirect(url_for('dashboard'))
+    path, old_raw, rtype = dns.get_zone_file_path(clean), request.form.get('old_raw').strip(), request.form.get('rtype')
+    name = dns.sanitize_record_name(request.form.get('name'), clean)
+    val = dns.construct_record_value(request.form, rtype)
+    
+    lines = fm.read_file(path).split('\n')
+    for i, l in enumerate(lines):
+        if l.strip() == old_raw: 
+            lines[i] = f"{name}\tIN\t{rtype}\t{val}"
+            break
+            
+    new_content = dns.increment_serial('\n'.join(lines))
+    is_valid, err = dns.check_zone_syntax(clean, new_content)
+    if not is_valid: 
+        flash(f"Linter Error: {err}", "error")
+        return redirect(url_for('manage_zone', domain=domain))
+        
+    dns.backup_zone(clean, fm.read_file(path))
+    fm.write_file(path, new_content)
+    subprocess.run(["rndc", "reload", clean])
+    fm.log_audit(session['username'], clean, f"Edited record {rtype} {name}")
+    flash("Record updated", "success")
+    return redirect(url_for('manage_zone', domain=domain))
+
+@app.route('/zones/manage/<path:domain>/rollback/<ts>', methods=['POST'])
+@login_required
+def rollback_zone(domain, ts):
+    clean = "." if domain == "root_zone" else domain
+    if not check_zone_access(clean): return redirect(url_for('dashboard'))
+    backup_file = os.path.join(config.BACKUP_DIR, f"{clean}_{ts}.db")
+    if os.path.exists(backup_file):
+        path = dns.get_zone_file_path(clean)
+        dns.backup_zone(clean, fm.read_file(path))
+        fm.write_file(path, fm.read_file(backup_file))
+        subprocess.run(["rndc", "reload", clean])
+        fm.log_audit(session['username'], clean, f"Rolled back to {ts}")
+        flash("Zone rolled back", "success")
+    else: 
+        flash("Backup not found", "error")
+    return redirect(url_for('manage_zone', domain=domain))
+
+@app.route('/zones/manage/<path:domain>/dnssec', methods=['POST'])
+@login_required
+def toggle_dnssec(domain):
+    clean = "." if domain == "root_zone" else domain
+    if not check_zone_access(clean): return redirect(url_for('dashboard'))
+    
+    conf = fm.read_file(config.LOCAL_CONF)
+    block = dns.get_zone_block(conf, clean)
+    
+    # BUG FIX: Αν δεν βρει τη ζώνη, να μην κρασάρει, αλλά να γυρίσει πίσω
+    if not block:
+        flash("Zone configuration block not found!", "error")
+        return redirect(url_for('manage_zone', domain=domain))
+        
+    if "dnssec-policy" in block:
+        new_block = re.sub(r'\s*dnssec-policy default;\s*inline-signing yes;', '', block)
+        fm.log_audit(session['username'], clean, "Disabled DNSSEC")
+    else:
+        new_block = block.replace('type master;', 'type master;\n    dnssec-policy default;\n    inline-signing yes;')
+        subprocess.run(["chown", "-R", "bind:bind", config.BIND_PATH])
+        fm.log_audit(session['username'], clean, "Enabled DNSSEC")
+        
+    fm.write_file(config.LOCAL_CONF, conf.replace(block, new_block))
+    subprocess.run(["rndc", "reconfig"])
+    subprocess.run(["rndc", "reload", clean])
+    return redirect(url_for('manage_zone', domain=domain))
+
+@app.route('/zones/manage/<path:domain>/edit_ips', methods=['POST'])
+@login_required
+def edit_zone_ips(domain):
+    clean = "." if domain == "root_zone" else domain
+    if not check_zone_access(clean): return redirect(url_for('dashboard'))
+    conf, block = fm.read_file(config.LOCAL_CONF), dns.get_zone_block(fm.read_file(config.LOCAL_CONF), clean)
+    ips = "".join([f" {x.strip()};" for x in request.form.getlist('ips[]') if x.strip()]) or " none;"
+    if "type slave" in block: new_block = re.sub(r'masters\s*\{.*?\};', f'masters {{{ips} }};', block, flags=re.S|re.I)
+    elif "type forward" in block: new_block = re.sub(r'forwarders\s*\{.*?\};', f'forwarders {{{ips} }};', block, flags=re.S|re.I)
+    fm.write_file(config.LOCAL_CONF, conf.replace(block, new_block))
+    subprocess.run(["rndc", "reconfig"])
+    return redirect(url_for('manage_zone', domain=domain))
+
+@app.route('/zones/assign', methods=['POST'])
+@admin_required
+def assign_zone():
+    d, o = request.form.get('domain'), request.form.get('owner')
+    s = fm.load_panel_settings()
+    s['zone_owners'][d] = o
+    fm.save_panel_settings(s)
+    flash("Assigned", "success")
+    return redirect(url_for('dashboard'))
+
+@app.route('/zones/manage/<path:domain>/cluster_sync', methods=['POST'])
+@login_required
+def cluster_sync_zone(domain):
+    clean = "." if domain == "root_zone" else domain.strip('/')
+    if not check_zone_access(clean): return redirect(url_for('dashboard'))
+    conf, block = fm.read_file(config.LOCAL_CONF), dns.get_zone_block(fm.read_file(config.LOCAL_CONF), clean)
+    if block and "type master" in block:
+        cm.broadcast_to_cluster('add_slave', clean)
+        time.sleep(1.5)
+        subprocess.run(["rndc", "notify", clean])
+        flash(f"Cluster Sync triggered for {clean}!", "success")
+    else: 
+        flash("Only Master zones can be synced.", "error")
+    return redirect(url_for('dashboard'))
+
+@app.route('/zones/manage/<path:domain>/sync')
+@login_required
+def sync_zone(domain):
+    clean = "." if domain == "root_zone" else domain
+    if not check_zone_access(clean): return redirect(url_for('dashboard'))
+    try: 
+        subprocess.run(["rndc", "retransfer", clean], check=True)
+        flash(f"Sync initiated", "success")
+    except: 
+        flash("Sync failed", "error")
+    return redirect(url_for('manage_zone', domain=domain))
+
+@app.route('/zones/manage/<path:domain>/convert_to_master')
+@login_required
+def convert_to_master(domain):
+    clean = "." if domain == "root_zone" else domain
+    if not check_zone_access(clean): return redirect(url_for('dashboard'))
+    conf, block = fm.read_file(config.LOCAL_CONF), dns.get_zone_block(fm.read_file(config.LOCAL_CONF), clean)
+    if not block or "type slave" not in block: return redirect(url_for('dashboard'))
+    
+    path_old = dns.get_zone_file_path(clean)
+    res = subprocess.run(["named-compilezone", "-f", "raw", "-F", "text", "-o", "-", clean, path_old], capture_output=True, text=True)
+    records_text = res.stdout if res.returncode == 0 else fm.read_file(path_old)
+    path_new = os.path.join(config.BIND_PATH, f"db.{clean}" if clean != "." else "db.root")
+    
+    fm.write_file(config.LOCAL_CONF, conf.replace(block, f'zone "{clean}" {{\n    type master;\n    file "{path_new}";\n}};'))
+    fm.write_file(path_new, records_text)
+    subprocess.run(["rndc", "reconfig"])
+    fm.log_audit(session['username'], clean, "Converted to Master")
+    return redirect(url_for('manage_zone', domain=domain))
+
+@app.route('/zones/manage/<path:domain>/convert_to_slave', methods=['POST'])
+@login_required
+def convert_to_slave(domain):
+    clean = "." if domain == "root_zone" else domain
+    if not check_zone_access(clean): return redirect(url_for('dashboard'))
+    conf, block = fm.read_file(config.LOCAL_CONF), dns.get_zone_block(fm.read_file(config.LOCAL_CONF), clean)
+    if not block or "type master" not in block: return redirect(url_for('dashboard'))
+    
+    path_old = dns.get_zone_file_path(clean)
+    path_new = os.path.join(config.CACHE_PATH, f"db.{clean}" if clean != "." else "db.root")
+    ips = "".join([f" {x.strip()};" for x in request.form.getlist('master_ips[]') if x.strip()])
+    
+    fm.write_file(config.LOCAL_CONF, conf.replace(block, f'zone "{clean}" {{\n    type slave;\n    file "{path_new}";\n    masters {{{ips} }};\n    masterfile-format text;\n}};'))
+    try: os.remove(path_old)
+    except: pass
+    subprocess.run(["rndc", "reconfig"])
+    fm.log_audit(session['username'], clean, "Converted to Slave")
+    return redirect(url_for('manage_zone', domain=domain))
+
+# --- ADBLOCK & ANALYTICS VIEWS ---
+@app.route('/adanalytics')
+@admin_required
+def adanalytics_page():
+    s = fm.load_panel_settings()
+    return render_template('adanalytics.html', log_enabled=(s.get('query_log_enabled') == 'yes'))
+
+@app.route('/adblock')
+@admin_required
+def adblock_dashboard():
+    settings = fm.load_panel_settings()
+    adblock_lists = fm.load_adblock_lists()
+    custom_rules = fm.load_custom_rules()
+    rpz_enabled = 'zone "rpz.blacklist"' in fm.read_file(config.LOCAL_CONF) and 'response-policy' in fm.read_file(config.OPTIONS_FILE)
+    return render_template('adblock.html', settings=settings, adblock_lists=adblock_lists, custom_rules=custom_rules, rpz_enabled=rpz_enabled)
+
+@app.route('/analytics')
+@admin_required
+def analytics(): 
+    return render_template('analytics.html')
+
+# --- CLUSTER & SETTINGS VIEWS ---
+@app.route('/cluster', methods=['GET', 'POST'])
+@admin_required
+def cluster_management():
+    settings = fm.load_panel_settings()
+    if request.method == 'POST':
+        action = request.form.get('action')
+        if action == 'save_ip':
+            settings['server_public_ip'] = request.form.get('server_public_ip', '').strip()
+            flash("Master IP Configuration Saved", "success")
+        elif action == 'add_node':
+            node_url = request.form.get('node_url', '').strip()
+            if node_url and not node_url.startswith('http'): node_url = 'http://' + node_url
+            node_url = node_url.rstrip('/')
+            new_node = {"name": request.form.get('node_name', '').strip(), "url": node_url, "api_key": request.form.get('node_api_key', '').strip()}
+            if new_node['name'] and new_node['url'] and new_node['api_key']:
+                settings['cluster_nodes'] = [n for n in settings.get('cluster_nodes', []) if n['name'].strip() != new_node['name']]
+                settings.setdefault('cluster_nodes', []).append(new_node)
+                flash("Cluster Node Added Successfully!", "success")
+        elif action == 'delete_node':
+            try:
+                node_idx = int(request.form.get('node_index'))
+                settings['cluster_nodes'].pop(node_idx)
+                flash("Cluster Node Removed", "success")
+            except: flash("Failed to remove node", "error")
+        elif action == 'regenerate_api':
+            settings['api_key'] = secrets.token_hex(32)
+            flash("API Key Regenerated. Update your nodes!", "success")
+        elif action == 'save_master':
+            m_url = request.form.get('master_url', '').strip()
+            if m_url and not m_url.startswith('http'): m_url = 'http://' + m_url
+            m_url = m_url.rstrip('/')
+            settings['master_node'] = {"name": request.form.get('master_name', '').strip(), "url": m_url}
+            flash("Master Node Linked", "success")
+        elif action == 'unlink_master':
+            settings['master_node'] = {}
+            flash("Master Node Unlinked", "success")
+        fm.save_panel_settings(settings)
+        return redirect(url_for('cluster_management'))
+    return render_template('cluster.html', settings=settings)
+
+@app.route('/settings')
+@admin_required
+def settings():
+    conf = fm.read_file(config.OPTIONS_FILE)
+    def parse_opt(regex, default='any'):
+        m = re.search(regex, conf, re.DOTALL | re.I)
+        return [x.strip() for x in m.group(1).split(';') if x.strip()] if m else [default]
+    b_opt = {
+        'listen_v4': parse_opt(r'listen-on\s*port\s+53\s*\{\s*(.*?)\s*\};'),
+        'allow_query': parse_opt(r'allow-query\s*\{\s*(.*?)\s*\};'),
+        'allow_transfer': parse_opt(r'allow-transfer\s*\{\s*(.*?)\s*\};', 'none'),
+        'also_notify': parse_opt(r'also-notify\s*\{\s*(.*?)\s*\};', ''),
+        'dnssec': (re.search(r'dnssec-validation\s+(.*?);', conf) or type('m',(),{'group':lambda x,y:'auto'})()).group(1)
+    }
+    return render_template('general_config.html', b_opt=b_opt, p_data=fm.load_panel_settings())
+
+@app.route('/settings/save', methods=['POST'])
+@admin_required
+def save_settings():
+    def join_ips(field): 
+        vals = [x.strip() for x in request.form.getlist(field) if x.strip()]
+        return "".join([f" {x};" for x in vals]) if vals else ""
+        
+    lv4 = join_ips('listen_v4[]') or " any;"
+    aq = join_ips('allow_query[]') or " any;"
+    at = join_ips('allow_transfer[]') or " none;"
+    an_raw = join_ips('also_notify[]')
+    an_line = f"    also-notify {{{an_raw} }};\n" if an_raw else ""
+    dnssec = request.form.get('dnssec', 'auto')
+    query_log = request.form.get('query_log_enabled', 'no')
+    
+    log_block = ""
+    if query_log == 'yes':
+        subprocess.run(["mkdir", "-p", "/var/log/named"])
+        subprocess.run(["touch", "/var/log/named/queries.log"])
+        subprocess.run(["chown", "-R", "bind:bind", "/var/log/named"])
+        log_block = """
+logging {
+    channel queries_log {
+        file "/var/log/named/queries.log" versions 3 size 5m;
+        severity info;
+        print-time yes;
+        print-category yes;
+        print-severity yes;
+    };
+    category queries { queries_log; };
+    category xfer-in { queries_log; };
+    category xfer-out { queries_log; };
+    category notify { queries_log; };
+    category rpz { queries_log; };
+};"""
+    
+    old_opts = fm.read_file(config.OPTIONS_FILE)
+    rpz_line = '    response-policy { zone "rpz.blacklist"; };\n' if 'response-policy' in old_opts else ''
+    
+    opt_content = f"options {{\n    directory \"/var/cache/bind\";\n    listen-on port 53 {{{lv4} }};\n    allow-query {{{aq} }};\n    allow-transfer {{{at} }};\n{an_line}{rpz_line}    dnssec-validation {dnssec};\n    querylog {'yes' if query_log=='yes' else 'no'};\n    auth-nxdomain no;\n}};\n{log_block}"
+    fm.write_file(config.OPTIONS_FILE, opt_content.strip())
+    
+    t_names, t_refresh, t_retry, t_expire, t_ttl, t_content = request.form.getlist('t_name[]'), request.form.getlist('t_refresh[]'), request.form.getlist('t_retry[]'), request.form.getlist('t_expire[]'), request.form.getlist('t_ttl[]'), request.form.getlist('t_content[]')
+    new_templates = {}
+    for idx, name in enumerate(t_names):
+        name = name.strip()
+        if not name: continue
+        keys, vals = request.form.getlist(f'custom_key_{idx}[]'), request.form.getlist(f'custom_val_{idx}[]')
+        custom_vars = {keys[i].strip().upper(): vals[i] for i in range(len(keys)) if keys[i].strip()}
+        new_templates[name] = {"refresh": t_refresh[idx], "retry": t_retry[idx], "expire": t_expire[idx], "ttl": t_ttl[idx], "content": t_content[idx], "custom_vars": custom_vars}
+        
+    s = fm.load_panel_settings()
+    s.update({"templates": new_templates, "stats_retention": int(request.form.get('stats_retention', 30)), "query_log_enabled": query_log})
+    fm.save_panel_settings(s)
+    subprocess.run(["systemctl", "restart", config.BIND_SERVICE])
+    flash("Settings Saved", "success")
+    return redirect(url_for('settings'))
+
+@app.route('/settings/view_mode', methods=['POST'])
+@login_required
+def update_view_mode():
+    mode = request.json.get('mode', 'cards')
+    users = fm.load_users()
+    if session['username'] in users: 
+        users[session['username']]['view_mode'] = mode
+        fm.save_users(users)
+    return jsonify({"status": "success"})
+
+# --- USERS VIEWS ---
+@app.route('/users')
+@admin_required
+def manage_users(): 
+    return render_template('users.html', users=fm.load_users(), all_templates=list(fm.load_panel_settings()['templates'].keys()))
+
+@app.route('/users/save', methods=['POST'])
+@admin_required
+def save_user():
+    action, old_u, u, p, r = request.form.get('action'), request.form.get('old_username'), request.form.get('username'), request.form.get('password'), request.form.get('role')
+    assigned = request.form.getlist('assigned_templates[]')
+    users = fm.load_users()
+    if action == 'add':
+        if u in users: flash("User exists", "error")
+        else: users[u] = {"password": generate_password_hash(p), "role": r, "assigned_templates": assigned, "view_mode": "cards"}
+    elif action == 'edit' and old_u in users:
+        pw = generate_password_hash(p) if p else users[old_u]['password']
+        users[old_u] = {"password": pw, "role": r, "assigned_templates": assigned, "view_mode": users[old_u].get('view_mode','cards')}
+        if u != old_u: users[u] = users.pop(old_u)
+    fm.save_users(users)
+    flash("User saved", "success")
+    return redirect(url_for('manage_users'))
+
+@app.route('/users/delete/<username>')
+@admin_required
+def delete_user(username):
+    users = fm.load_users()
+    if username != 'admin' and username in users:
+        del users[username]
+        fm.save_users(users)
+        flash("User deleted", "success")
+    return redirect(url_for('manage_users'))
+
+# --- ADBLOCK API (MASTER) ---
+@app.route('/api/adblock/update', methods=['POST'])
+@admin_required
+def route_update_adblock():
+    result = ab.generate_and_apply_rpz()
+    if result.get("status") == "success":
+        # BUG FIX: Ειδοποιούμε το Cluster ότι κάναμε update!
+        cm.broadcast_adblock_to_cluster('enable')
+        return jsonify(result)
+    else:
+        return jsonify(result), 500
+
+@app.route('/api/adblock/disable', methods=['POST'])
+@admin_required
+def route_disable_adblock():
+    result = ab.disable_rpz()
+    cm.broadcast_adblock_to_cluster('disable')
+    return jsonify(result)
+
+@app.route('/api/adblock/sync_cluster', methods=['POST'])
+@admin_required
+def route_sync_adblock_cluster():
+    rpz_enabled = 'zone "rpz.blacklist"' in fm.read_file(config.LOCAL_CONF)
+    action = 'enable' if rpz_enabled else 'disable'
+    
+    settings = fm.load_panel_settings()
+    nodes = settings.get('cluster_nodes', [])
+    lists = fm.load_adblock_lists()
+    rules = fm.load_custom_rules()
+    count = settings.get('adblock_domains_count', 0)
+    update_time = settings.get('adblock_last_update', 'Never')
+    
+    success_count = 0
+    for node in nodes:
+        try:
+            url = f"{node['url'].rstrip('/')}/api/v1/cluster/adblock"
+            headers = {"X-API-Key": node['api_key'], "Content-Type": "application/json"}
+            payload = {"action": action, "lists": lists, "rules": rules, "count": count, "time": update_time}
+            
+            proxy_handler = urllib.request.ProxyHandler({})
+            opener = urllib.request.build_opener(proxy_handler)
+            req = urllib.request.Request(url, data=json.dumps(payload).encode('utf-8'), headers=headers, method='POST')
+            with opener.open(req, timeout=10) as response:
+                if response.status == 200: success_count += 1
+        except Exception as e:
+            print(f"Cluster AdBlock Sync FAIL: {node['name']} -> {e}")
+            
+    return jsonify({"status": "success", "message": f"Sent AdBlock config to {success_count}/{len(nodes)} cluster nodes."})
+
+@app.route('/api/adblock/toggle_list', methods=['POST'])
+@admin_required
+def toggle_adblock_list():
+    list_id = request.json.get('id')
+    state = request.json.get('state')
+    lists = fm.load_adblock_lists()
+    for l in lists:
+        if l['id'] == list_id:
+            l['enabled'] = state
+            break
+    fm.save_adblock_lists(lists)
+    return jsonify({"status": "success"})
+
+@app.route('/api/adblock/add_custom', methods=['POST'])
+@admin_required
+def add_custom_adblock():
+    name = request.form.get('name', '').strip()
+    url = request.form.get('url', '').strip()
+    desc = request.form.get('desc', 'Custom User Blocklist').strip()
+    if not name or not url: return redirect(url_for('adblock_dashboard'))
+    lists = fm.load_adblock_lists()
+    new_id = "custom_" + str(int(time.time()))
+    lists.append({"id": new_id, "name": name, "desc": desc, "url": url, "enabled": True, "category": "Custom", "is_custom": True, "info": url})
+    fm.save_adblock_lists(lists)
+    flash("Custom list added", "success")
+    return redirect(url_for('adblock_dashboard'))
+
+@app.route('/api/adblock/edit_custom', methods=['POST'])
+@admin_required
+def edit_custom_adblock():
+    list_id = request.form.get('list_id')
+    name = request.form.get('name', '').strip()
+    url = request.form.get('url', '').strip()
+    desc = request.form.get('desc', '').strip()
+    lists = fm.load_adblock_lists()
+    for l in lists:
+        if l['id'] == list_id and l.get('is_custom'):
+            l['name'], l['desc'], l['url'], l['info'] = name, desc, url, url
+            break
+    fm.save_adblock_lists(lists)
+    flash("Custom list updated", "success")
+    return redirect(url_for('adblock_dashboard'))
+
+@app.route('/api/adblock/delete_list/<list_id>', methods=['GET'])
+@admin_required
+def delete_adblock_list(list_id):
+    lists = [l for l in fm.load_adblock_lists() if l['id'] != list_id or not l['is_custom']]
+    fm.save_adblock_lists(lists)
+    flash("List deleted", "success")
+    return redirect(url_for('adblock_dashboard'))
+
+@app.route('/api/adblock/rules/add', methods=['POST'])
+@admin_required
+def add_adblock_rule():
+    rtype, domain = request.form.get('type'), request.form.get('domain', '').strip().lower()
+    domain = re.sub(r'^https?://', '', domain).rstrip('/')
+    rules = fm.load_custom_rules()
+    if domain not in rules[rtype]: rules[rtype].append(domain)
+    fm.save_custom_rules(rules)
+    return jsonify({"status": "success"})
+
+@app.route('/api/adblock/rules/edit', methods=['POST'])
+@admin_required
+def edit_adblock_rule():
+    rtype = request.form.get('type')
+    old_domain = request.form.get('old_domain', '').strip()
+    new_domain = re.sub(r'^https?://', '', request.form.get('new_domain', '').strip().lower()).rstrip('/')
+    rules = fm.load_custom_rules()
+    if old_domain in rules.get(rtype, []):
+        rules[rtype][rules[rtype].index(old_domain)] = new_domain
+    fm.save_custom_rules(rules)
+    return jsonify({"status": "success"})
+
+@app.route('/api/adblock/rules/delete', methods=['POST'])
+@admin_required
+def delete_adblock_rule():
+    rtype, domain = request.form.get('type'), request.form.get('domain', '').strip()
+    rules = fm.load_custom_rules()
+    rules[rtype] = [d for d in rules.get(rtype, []) if d != domain]
+    fm.save_custom_rules(rules)
+    return jsonify({"status": "success"})
+
+# --- STATS API ---
+@app.route('/api/adblock/stats_live')
+@admin_required
+def api_adblock_stats_live():
+    with stats.stats_lock:
+        try:
+            with open(config.STATS_FILE, "r") as f: st = json.load(f)
+        except: st = {}
+        
+    now = datetime.datetime.now().strftime("%Y-%m-%d")
+    day_data = st.get(now, {})
+    
+    total_q = day_data.get("queries", 0)
+    total_b = day_data.get("blocked_queries", 0)
+    
+    top_domains = sorted(day_data.get("blocked_domains", {}).items(), key=lambda x: x[1], reverse=True)[:10]
+    top_clients = sorted(day_data.get("blocked_clients", {}).items(), key=lambda x: x[1], reverse=True)[:10]
+    percentage = round((total_b / total_q * 100), 2) if total_q > 0 else 0
+    
+    return jsonify({
+        "total_queries": total_q,
+        "total_blocked": total_b,
+        "percentage": percentage,
+        "top_domains": top_domains,
+        "top_clients": top_clients,
+        "timeline": day_data.get("timeline", {}),
+        "block_timeline": day_data.get("block_timeline", {})
+    })
+
+@app.route('/api/stats')
+@admin_required
+def api_stats():
+    with stats.stats_lock: return fm.read_file(config.STATS_FILE) or "{}"
+
+@app.route('/api/ip_queries/<ip>')
+@admin_required
+def get_ip_queries(ip):
+    if not re.match(r'^[\d\.\:a-fA-F]+$', ip): return jsonify({"error": "Invalid IP format"})
+    try:
+        cmd = f"grep -i '{ip}#' {config.QUERY_LOG_PATH} | tail -n 50"
+        proc = subprocess.run(cmd, shell=True, capture_output=True, text=True)
+        lines = proc.stdout.strip().split('\n')
+        queries = []
+        for line in reversed(lines):
+            if not line: continue
+            m = re.search(r'query:\s+([^\s]+)\s+IN\s+([A-Z0-9]+)', line, re.I)
+            if m:
+                time_str = " ".join(line.split()[:2]) if len(line.split()) > 2 else ""
+                queries.append({"time": time_str, "domain": m.group(1), "type": m.group(2)})
+        return jsonify({"ip": ip, "queries": queries})
+    except Exception as e: return jsonify({"error": str(e)})
+
+# --- UTILS API ---
+@app.route('/api/zones/test_dig', methods=['POST'])
+@login_required
+def test_dig_transfer():
+    domain, target_ip = request.json.get('domain'), request.json.get('target_ip', '127.0.0.1')
+    if not domain: return jsonify({"error": "No domain provided"}), 400
+    
+    block = dns.get_zone_block(fm.read_file(config.LOCAL_CONF), domain)
+    if block:
+        m = re.search(r'masters\s*\{\s*([^;]+);', block)
+        if m: target_ip = m.group(1).strip()
+
+    try:
+        output = subprocess.run(["dig", f"@{target_ip}", domain, "AXFR"], capture_output=True, text=True, timeout=8).stdout
+        if "Transfer failed" in output or "connection refused" in output.lower() or not output.strip():
+            return jsonify({"success": False, "raw": output, "message": f"Transfer failed from Master ({target_ip})"})
+
+        analysis, total_count = {}, 0
+        for line in output.split('\n'):
+            if not line or line.startswith((';', '\n')): continue
+            for p in line.split():
+                if p in ['A', 'AAAA', 'MX', 'TXT', 'CNAME', 'NS', 'SOA', 'SRV', 'PTR']:
+                    analysis[p] = analysis.get(p, 0) + 1
+                    total_count += 1
+                    break
+        return jsonify({"success": True, "total": total_count, "analysis": analysis, "raw": output, "target": target_ip})
+    except Exception as e: return jsonify({"success": False, "message": str(e)})
+
+@app.route('/api/service/status')
+@admin_required
+def get_service_status():
+    try:
+        res = subprocess.run(["systemctl", "is-active", config.BIND_SERVICE], capture_output=True, text=True)
+        is_active = res.stdout.strip() == "active"
+        uptime = "Unknown"
+        if is_active:
+            res_up = subprocess.run(["systemctl", "show", config.BIND_SERVICE, "--property=ActiveEnterTimestamp"], capture_output=True, text=True)
+            uptime = res_up.stdout.replace("ActiveEnterTimestamp=", "").strip()
+        return jsonify({"status": "active" if is_active else "inactive", "uptime": uptime})
+    except: return jsonify({"status": "error", "uptime": "N/A"})
+
+@app.route('/service/<action>')
+@admin_required
+def service_control(action):
+    try:
+        if action == 'reload': subprocess.run(["rndc", "reload"], check=True)
+        else: subprocess.run(["systemctl", action, config.BIND_SERVICE], check=True)
+    except: pass
+    return redirect(request.referrer or url_for('dashboard'))
+
+@app.route('/backup/export')
+@admin_required
+def export_backup():
+    memory_file = io.BytesIO()
+    zip_f = zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED)
+    
+    core_files = [
+        config.LOCAL_CONF, config.OPTIONS_FILE, config.PANEL_SETTINGS, 
+        config.USERS_FILE, config.STATS_FILE, config.ADBLOCK_LISTS_FILE, 
+        config.ADBLOCK_CUSTOM_RULES_FILE
+    ]
+    for f in core_files:
+        if os.path.exists(f): 
+            zip_f.write(f, os.path.basename(f))
+            
+    # BUG FIX: Διπλός έλεγχος os.path.exists() για να μην κρασάρει το zip
+    for f in os.listdir(config.BIND_PATH):
+        if f.startswith("db.") and f != "db.rpz.blacklist": 
+            full_path = os.path.join(config.BIND_PATH, f)
+            if os.path.exists(full_path):
+                zip_f.write(full_path, f)
+            
+    zip_f.close()
+    memory_file.seek(0)
+    return send_file(memory_file, download_name=f"bind_backup_{time.strftime('%Y%m%d_%H%M%S')}.zip", as_attachment=True)
+
+@app.route('/backup/import', methods=['POST'])
+@admin_required
+def import_backup():
+    file = request.files.get('backup_file')
+    if file and file.filename.endswith('.zip'):
+        with zipfile.ZipFile(file, 'r') as zf: zf.extractall(config.BIND_PATH)
+        subprocess.run(["systemctl", "restart", config.BIND_SERVICE])
+        flash("Restored", "success")
+    return redirect(url_for('dashboard'))
+
+# --- CLUSTER SLAVE RECEIVERS (V1 API) ---
+@app.route('/api/v1/cluster/adblock', methods=['POST'])
+@api_required
+def api_cluster_adblock():
+    data = request.json
+    action, master_ip = data.get('action'), request.headers.get('X-Forwarded-For', request.remote_addr).split(',')[0].strip()
+
+    if 'lists' in data: fm.save_adblock_lists(data['lists'])
+    if 'rules' in data: fm.save_custom_rules(data['rules'])
+    s = fm.load_panel_settings()
+    if 'count' in data: s['adblock_domains_count'] = data['count']
+    if 'time' in data: s['adblock_last_update'] = data['time']
+    fm.save_panel_settings(s)
+
+    conf, opts = fm.read_file(config.LOCAL_CONF), fm.read_file(config.OPTIONS_FILE)
+
+    if action == 'enable':
+        if 'zone "rpz.blacklist"' not in conf:
+            path = os.path.join(config.CACHE_PATH, "db.rpz.blacklist")
+            conf += f'\nzone "rpz.blacklist" {{\n    type slave;\n    file "{path}";\n    masters {{ {master_ip}; }};\n    allow-query {{ any; }};\n}};\n'
+            fm.write_file(config.LOCAL_CONF, conf)
+        if "response-policy" not in opts:
+            opts = re.sub(r'(options\s*\{)', r'\1\n    response-policy { zone "rpz.blacklist"; };', opts)
+            fm.write_file(config.OPTIONS_FILE, opts)
+    else:
+        block = dns.get_zone_block(conf, "rpz.blacklist")
+        if block: fm.write_file(config.LOCAL_CONF, conf.replace(block, ""))
+        opts = re.sub(r'\s*response-policy\s*\{\s*zone\s*"rpz\.blacklist";\s*\};\s*', '\n', opts)
+        fm.write_file(config.OPTIONS_FILE, opts)
+
+    subprocess.run(["systemctl", "restart", config.BIND_SERVICE])
+    return jsonify({"status": "success", "message": "RPZ synced on slave."})
+
+@app.route('/api/v1/cluster/zone', methods=['POST'])
+@api_required
+def api_add_slave():
+    data = request.json
+    if not data or 'domain' not in data: return jsonify({"status": "error", "message": "Missing parameters"}), 400
+        
+    domain = re.sub(r'[^a-zA-Z0-9\.\-\/]', '', data['domain']).replace('../', '')
+    master_ip = request.headers.get('X-Forwarded-For', request.remote_addr).split(',')[0].strip()
+    
+    conf = fm.read_file(config.LOCAL_CONF)
+    if f'zone "{domain}"' in conf: return jsonify({"status": "error", "message": "Zone already exists"}), 409
+        
+    path = os.path.join(config.CACHE_PATH, f"db.{domain}")
+    conf += f'\nzone "{domain}" {{\n    type slave;\n    file "{path}";\n    masters {{ {master_ip}; }};\n    masterfile-format text;\n}};\n'
+    
+    fm.write_file(config.LOCAL_CONF, conf)
+    subprocess.run(["rndc", "reconfig"])
+    fm.log_audit("API_SYNC", domain, f"Auto-created as Slave (Master Auto-Detected: {master_ip})")
+    
+    settings = fm.load_panel_settings()
+    settings['master_node'] = {"name": f"Auto-Linked Master ({master_ip})", "url": f"http://{master_ip}:5000"}
+    fm.save_panel_settings(settings)
+    return jsonify({"status": "success", "message": f"Slave zone {domain} configured."})
+
+@app.route('/api/v1/cluster/zone/<domain>', methods=['DELETE'])
+@api_required
+def api_delete_zone(domain):
+    clean = re.sub(r'[^a-zA-Z0-9\.\-\/]', '', domain).replace('../', '')
+    conf = fm.read_file(config.LOCAL_CONF)
+    block = dns.get_zone_block(conf, clean)
+    if not block: return jsonify({"status": "error", "message": "Zone not found"}), 404
+        
+    path = dns.get_zone_file_path(clean)
+    fm.write_file(config.LOCAL_CONF, conf.replace(block, ""))
+    subprocess.run(["rndc", "reconfig"])
+    try: os.remove(path)
+    except: pass
+    
+    fm.log_audit("API_SYNC", clean, "Auto-deleted via Cluster Sync")
+    return jsonify({"status": "success", "message": f"Zone {clean} deleted."})
+
+@app.route('/api/v1/cluster/ping', methods=['GET'])
+@api_required
+def api_ping():
+    master_ip = request.headers.get('X-Forwarded-For', request.remote_addr).split(',')[0].strip()
+    settings = fm.load_panel_settings()
+    if settings.get('master_node', {}).get('url') != f"http://{master_ip}:5000":
+        settings['master_node'] = {"name": f"Master Node ({master_ip})", "url": f"http://{master_ip}:5000"}
+        fm.save_panel_settings(settings)
+    return jsonify({"status": "success", "message": "Node is synchronized"})
+
+@app.route('/cluster/check_node', methods=['POST'])
+@login_required
+def check_cluster_node():
+    node_name = request.json.get('node_name')
+    node = next((n for n in fm.load_panel_settings().get('cluster_nodes', []) if n['name'] == node_name), None)
+    if not node: return jsonify({"status": "error"})
+    
+    try:
+        url = f"{node['url'].rstrip('/')}/api/v1/cluster/ping"
+        proxy_handler = urllib.request.ProxyHandler({})
+        opener = urllib.request.build_opener(proxy_handler)
+        req = urllib.request.Request(url, headers={"X-API-Key": node['api_key']})
+        with opener.open(req, timeout=10) as response:
+            if response.status == 200: return jsonify({"status": "online"})
+            
+    except urllib.error.HTTPError as e:
+        print(f"\n[CLUSTER HTTP ERROR] Node: {node_name} -> Κωδικός: {e.code}\n")
+        if e.code == 401: return jsonify({"status": "auth_error"})
+    except Exception as e:
+        print(f"\n[CLUSTER NETWORK ERROR] Node: {node_name} -> Αιτία: {str(e)}\n")
+        
+    return jsonify({"status": "offline"})
+# --- REMOTE SERVICE CONTROL (SLAVE RECEIVER) ---
+@app.route('/api/v1/cluster/service', methods=['POST'])
+@api_required
+def api_cluster_service():
+    action = request.json.get('action')
+    if action in ['start', 'stop', 'restart', 'reload']:
+        try:
+            subprocess.run(["systemctl", action, config.BIND_SERVICE], check=True)
+            return jsonify({"status": "success", "message": f"Service {action}ed successfully"})
+        except Exception as e:
+            return jsonify({"status": "error", "message": str(e)})
+    elif action == 'status':
+        try:
+            res = subprocess.run(["systemctl", "is-active", config.BIND_SERVICE], capture_output=True, text=True)
+            is_active = res.stdout.strip() == "active"
+            return jsonify({"status": "success", "service_status": "active" if is_active else "inactive"})
+        except:
+            return jsonify({"status": "error", "service_status": "unknown"})
+    return jsonify({"status": "error", "message": "Invalid action"})
+
+# --- REMOTE SERVICE PROXY (MASTER TO SLAVE) ---
+@app.route('/cluster/node_service', methods=['POST'])
+@login_required
+def cluster_node_service():
+    node_name = request.json.get('node_name')
+    action = request.json.get('action')
+    node = next((n for n in fm.load_panel_settings().get('cluster_nodes', []) if n['name'] == node_name), None)
+    if not node: return jsonify({"status": "error", "message": "Node not found"})
+    
+    try:
+        url = f"{node['url'].rstrip('/')}/api/v1/cluster/service"
+        proxy_handler = urllib.request.ProxyHandler({})
+        opener = urllib.request.build_opener(proxy_handler)
+        payload = {"action": action}
+        req = urllib.request.Request(url, data=json.dumps(payload).encode('utf-8'), headers={"X-API-Key": node['api_key'], "Content-Type": "application/json"}, method='POST')
+        
+        with opener.open(req, timeout=10) as response:
+            return jsonify(json.loads(response.read().decode('utf-8')))
+    except Exception as e:
+        return jsonify({"status": "error", "message": str(e)})
+
+    # --- GLOBAL CLUSTER SYNC (MASTER ACTION) ---
+    @app.route('/api/cluster/sync_all', methods=['POST'])
+    @admin_required
+    def sync_all_cluster():
+        conf = fm.read_file(config.LOCAL_CONF)
+        # Βρίσκουμε όλα τα domains που είναι "type master"
+        zones = re.findall(r'zone\s+"([^"]+)"\s*\{.*?type\s+master;', conf, re.DOTALL)
+        # Εξαιρούμε το RPZ, θα συγχρονιστεί από το AdBlock μενού
+        zones = [z for z in zones if z != "rpz.blacklist"]
+        
+        if not zones:
+            return jsonify({"status": "error", "message": "No Master zones found to sync."})
+            
+        def bulk_sync():
+            for z in zones:
+                cm.broadcast_to_cluster('add_slave', z)
+                time.sleep(0.5) # Μικρή καθυστέρηση για να μην "μπουκώσει" ο Slave
+                
+        threading.Thread(target=bulk_sync, daemon=True).start()
+        return jsonify({"status": "success", "message": f"Started pushing {len(zones)} zones to all Nodes!"})
+if __name__ == '__main__':
+    # Είναι σημαντικό το threaded=True για να μην κάνει "block" το UI στο self-ping
+    app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)

+ 97 - 0
config.py

@@ -0,0 +1,97 @@
+import os
+import ssl
+
+# --- PATHS ---
+BIND_PATH = "/etc/bind/"
+CACHE_PATH = "/var/cache/bind/"
+LOCAL_CONF = os.path.join(BIND_PATH, "named.conf.local")
+OPTIONS_FILE = os.path.join(BIND_PATH, "named.conf.options")
+PANEL_SETTINGS = os.path.join(BIND_PATH, "panel_settings.json")
+USERS_FILE = os.path.join(BIND_PATH, "users.json")
+STATS_FILE = os.path.join(BIND_PATH, "panel_stats.json")
+AUDIT_FILE = os.path.join(BIND_PATH, "audit_log.json")
+BACKUP_DIR = os.path.join(BIND_PATH, "zone_backups")
+BIND_SERVICE = "bind9"
+QUERY_LOG_PATH = "/var/log/named/queries.log"
+ADBLOCK_DB_PATH = os.path.join(BIND_PATH, "db.rpz.blacklist")
+ADBLOCK_LISTS_FILE = os.path.join(BIND_PATH, "adblock_lists.json")
+ADBLOCK_CUSTOM_RULES_FILE = os.path.join(BIND_PATH, "adblock_custom_rules.json")
+
+# Create Backup Directory if it doesn't exist
+os.makedirs(BACKUP_DIR, exist_ok=True)
+
+# --- SSL CONTEXT (For internal Cluster API calls) ---
+ssl_ctx = ssl.create_default_context()
+ssl_ctx.check_hostname = False
+ssl_ctx.verify_mode = ssl.CERT_NONE
+
+# --- DEFAULT ADBLOCK LISTS ---
+DEFAULT_ADBLOCK_LISTS = [
+    {"id": "1hosts_lite", "name": "1Hosts (Lite)", "desc": "Lists that block tracking and advertising on most of the devices", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_24.txt", "enabled": False, "category": "General", "is_custom": False, "info": "https://badmojr.github.io/1Hosts/"},
+    {"id": "1hosts_xtra", "name": "1Hosts (Xtra)", "desc": "Lists that block tracking and advertising on most of the devices", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_70.txt", "enabled": False, "category": "General", "is_custom": False, "info": "https://badmojr.github.io/1Hosts/"},
+    {"id": "adguard_dns_filter", "name": "AdGuard DNS filter", "desc": "Lists that block tracking and advertising on most of the devices", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt", "enabled": True, "category": "General", "is_custom": False, "info": "https://github.com/AdguardTeam/AdGuardSDNSFilter"},
+    {"id": "adguard_popup_filter", "name": "AdGuard DNS Popup Hosts filter", "desc": "Lists that block tracking and advertising on most of the devices", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_59.txt", "enabled": False, "category": "General", "is_custom": False, "info": "https://github.com/AdguardTeam/AdGuardSDNSFilter"},
+    {"id": "awavenue_ads_rule", "name": "AWAvenue Ads Rule", "desc": "Lists that block tracking and advertising on most of the devices", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_53.txt", "enabled": False, "category": "General", "is_custom": False, "info": "https://awavenue.top/"},
+    {"id": "dan_pollocks_list", "name": "Dan Pollock's List", "desc": "Lists that block tracking and advertising on most of the devices", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_4.txt", "enabled": False, "category": "General", "is_custom": False, "info": "https://someonewhocares.org/"},
+    {"id": "hagezi_multinormal", "name": "HaGeZi's Normal Blocklist", "desc": "Lists that block tracking and advertising on most of the devices", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_34.txt", "enabled": False, "category": "General", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists"},
+    {"id": "hagezi_pro", "name": "HaGeZi's Pro Blocklist", "desc": "Lists that block tracking and advertising on most of the devices", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_48.txt", "enabled": False, "category": "General", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists"},
+    {"id": "hagezi_pro_plus", "name": "HaGeZi's Pro++ Blocklist", "desc": "Lists that block tracking and advertising on most of the devices", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_51.txt", "enabled": False, "category": "General", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists"},
+    {"id": "hagezi_ultimate", "name": "HaGeZi's Ultimate Blocklist", "desc": "Lists that block tracking and advertising on most of the devices", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_49.txt", "enabled": False, "category": "General", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists"},
+    {"id": "oisd_basic", "name": "OISD Blocklist Small", "desc": "Lists that block tracking and advertising on most of the devices", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_5.txt", "enabled": False, "category": "General", "is_custom": False, "info": "https://oisd.nl/"},
+    {"id": "oisd_full", "name": "OISD Blocklist Big", "desc": "Lists that block tracking and advertising on most of the devices", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_27.txt", "enabled": False, "category": "General", "is_custom": False, "info": "https://oisd.nl/"},
+    {"id": "peter_lowe_list", "name": "Peter Lowe's Blocklist", "desc": "Lists that block tracking and advertising on most of the devices", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_3.txt", "enabled": False, "category": "General", "is_custom": False, "info": "https://pgl.yoyo.org/adservers/"},
+    {"id": "shadowwhisperer_tracking_list", "name": "ShadowWhisperer Tracking List", "desc": "Lists that block tracking and advertising on most of the devices", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_69.txt", "enabled": False, "category": "General", "is_custom": False, "info": "https://github.com/ShadowWhisperer/BlockLists"},
+    {"id": "steven_blacks_list", "name": "Steven Black's List", "desc": "Lists that block tracking and advertising on most of the devices", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_33.txt", "enabled": False, "category": "General", "is_custom": False, "info": "https://github.com/StevenBlack/hosts"},
+    
+    {"id": "dandelion_sprouts_anti_push", "name": "Dandelion Sprout's Anti Push Notifications", "desc": "Other blocklists", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_39.txt", "enabled": False, "category": "Other", "is_custom": False, "info": "https://github.com/DandelionSprout/adfilt"},
+    {"id": "dandelion_sprouts_game_console", "name": "Dandelion Sprout's Game Console Adblock List", "desc": "Other blocklists", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_6.txt", "enabled": False, "category": "Other", "is_custom": False, "info": "https://github.com/DandelionSprout/adfilt"},
+    {"id": "hagezi_allowlist_referral", "name": "HaGeZi's Allowlist Referral", "desc": "Other blocklists", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_45.txt", "enabled": False, "category": "Other", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists#referral"},
+    {"id": "hagezi_antipiracy", "name": "HaGeZi's Anti-Piracy Blocklist", "desc": "Other blocklists", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_46.txt", "enabled": False, "category": "Other", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists#piracy"},
+    {"id": "hagezi_apple_tracker", "name": "HaGeZi's Apple Tracker Blocklist", "desc": "Other blocklists", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_67.txt", "enabled": False, "category": "Other", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists"},
+    {"id": "hagezi_gambling", "name": "HaGeZi's Gambling Blocklist", "desc": "Other blocklists", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_47.txt", "enabled": False, "category": "Other", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists#gambling"},
+    {"id": "hagezi_oppo_realme", "name": "HaGeZi's OPPO & Realme Tracker Blocklist", "desc": "Other blocklists", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_66.txt", "enabled": False, "category": "Other", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists"},
+    {"id": "hagezi_samsung_tracker", "name": "HaGeZi's Samsung Tracker Blocklist", "desc": "Other blocklists", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_61.txt", "enabled": False, "category": "Other", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists"},
+    {"id": "hagezi_vivo_tracker", "name": "HaGeZi's Vivo Tracker Blocklist", "desc": "Other blocklists", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_65.txt", "enabled": False, "category": "Other", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists"},
+    {"id": "hagezi_windows_office", "name": "HaGeZi's Windows/Office Tracker Blocklist", "desc": "Other blocklists", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_63.txt", "enabled": False, "category": "Other", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists"},
+    {"id": "hagezi_xiaomi_tracking", "name": "HaGeZi's Xiaomi Tracker Blocklist", "desc": "Other blocklists", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_60.txt", "enabled": False, "category": "Other", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists"},
+    {"id": "no_google", "name": "No Google", "desc": "Other blocklists", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_37.txt", "enabled": False, "category": "Other", "is_custom": False, "info": "https://github.com/nickspaargaren/no-google"},
+    {"id": "perflyst_smart_tv", "name": "Perflyst and Dandelion Sprout's Smart-TV Blocklist", "desc": "Other blocklists", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_7.txt", "enabled": False, "category": "Other", "is_custom": False, "info": "https://github.com/Perflyst/PiHoleBlocklist"},
+    {"id": "shadowwhisperers_dating", "name": "ShadowWhisperer's Dating List", "desc": "Other blocklists", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_57.txt", "enabled": False, "category": "Other", "is_custom": False, "info": "https://github.com/ShadowWhisperer/BlockLists"},
+    {"id": "ukrainian_security", "name": "Ukrainian Security Filter", "desc": "Other blocklists", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_62.txt", "enabled": False, "category": "Other", "is_custom": False, "info": "https://github.com/braveinnovators/ukrainian-security-filter"},
+
+    {"id": "chn_adrules", "name": "CHN: AdRules DNS List", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_29.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "https://github.com/Cats-Team/AdRules"},
+    {"id": "chn_anti_ad", "name": "CHN: anti-AD", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_21.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "https://anti-ad.net/"},
+    {"id": "hun_hufilter", "name": "HUN: Hufilter", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_35.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "https://github.com/hufilter/hufilter"},
+    {"id": "idn_abpindo", "name": "IDN: ABPindo", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_22.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "https://github.com/ABPindo/indonesianadblockrules"},
+    {"id": "irn_persianblocker", "name": "IRN: PersianBlocker list", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_19.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "https://github.com/MasterKia/PersianBlocker"},
+    {"id": "isr_easylist_hebrew", "name": "ISR: EasyList Hebrew", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_43.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "https://github.com/easylist/EasyListHebrew"},
+    {"id": "kor_list_kr", "name": "KOR: List-KR DNS", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_25.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "https://github.com/List-KR/List-KR"},
+    {"id": "kor_youslist", "name": "KOR: YousList", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_15.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "https://github.com/yous/YousList"},
+    {"id": "lit_easylist_lithuania", "name": "LIT: EasyList Lithuania", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_36.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "https://github.com/EasyList-Lithuania/easylist_lithuania"},
+    {"id": "mkd_macedonian_pi_hole", "name": "MKD: Macedonian Pi-hole Blocklist", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_20.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "https://github.com/cchevy/macedonian-pi-hole-blocklist"},
+    {"id": "nor_dandelion_sprouts", "name": "NOR: Dandelion Sprouts nordiske filtre", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_13.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "https://github.com/DandelionSprout/adfilt"},
+    {"id": "pol_cert_polska", "name": "POL: CERT Polska List of malicious domains", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_41.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "https://cert.pl/posts/2020/03/ostrzezenia_phishing/"},
+    {"id": "pol_polish_filters", "name": "POL: Polish filters for Pi-hole", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_14.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "https://www.certyficate.it/"},
+    {"id": "swe_frellwit_swedish", "name": "SWE: Frellwit's Swedish Hosts File", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_17.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "https://github.com/lassekongo83/Frellwits-filter-lists/"},
+    {"id": "tur_turk_adlist", "name": "TUR: turk-adlist", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_26.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "https://github.com/bkrucarci/turk-adlist"},
+    {"id": "tur_turkish_ad_hosts", "name": "TUR: Turkish Ad Hosts", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_40.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "https://github.com/symbuzzer/Turkish-Ad-Hosts"},
+    {"id": "vnm_abpvn", "name": "VNM: ABPVN List", "desc": "Lists that focus on regional ads and tracking servers", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_16.txt", "enabled": False, "category": "Regional", "is_custom": False, "info": "http://abpvn.com/"},
+
+    {"id": "curben_phishing_filter", "name": "Phishing URL Blocklist", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_30.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://gitlab.com/malware-filter/phishing-filter"},
+    {"id": "dandelion_sprouts_anti_malware_list", "name": "Dandelion Sprout's Anti-Malware List", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_12.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://github.com/DandelionSprout/adfilt"},
+    {"id": "hagezi_badware_hoster_blocklist", "name": "HaGeZi's Badware Hoster Blocklist", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_55.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists"},
+    {"id": "hagezi_dns_rebind_protection", "name": "HaGeZi's DNS Rebind Protection", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_71.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists"},
+    {"id": "hagezi_dyndns_blocklist", "name": "HaGeZi's DynDNS Blocklist", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_54.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists"},
+    {"id": "hagezi_encrypted_dns_vpn_tor_proxy_bypass", "name": "HaGeZi's Encrypted DNS/VPN/TOR Bypass", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_52.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists#bypass"},
+    {"id": "hagezi_the_worlds_most_abused_tlds", "name": "HaGeZi's The World's Most Abused TLDs", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_56.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists"},
+    {"id": "hagezi_threat_intelligence_feeds", "name": "HaGeZi's Threat Intelligence Feeds", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_44.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists"},
+    {"id": "hagezi_url_shortener_blocklist", "name": "HaGeZi's URL Shortener Blocklist", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_68.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://github.com/hagezi/dns-blocklists"},
+    {"id": "nocoin_filter_list", "name": "NoCoin Filter List", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_8.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://github.com/hoshsadiq/adblock-nocoin-list/"},
+    {"id": "phishing_army", "name": "Phishing Army", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_18.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://phishing.army/"},
+    {"id": "scam_blocklist_by_durablenapkin", "name": "Scam Blocklist by DurableNapkin", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_10.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://github.com/durablenapkin/scamblocklist"},
+    {"id": "shadowwhisperers_malware_list", "name": "ShadowWhisperer's Malware List", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_42.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://github.com/ShadowWhisperer/BlockLists"},
+    {"id": "staklerware_indicators_list", "name": "Stalkerware Indicators List", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_31.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://github.com/AssoEchap/stalkerware-indicators"},
+    {"id": "the_big_list_of_hacked_malware_web_sites", "name": "The Big List of Hacked Malware Web Sites", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_9.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://github.com/mitchellkrogza/The-Big-List-of-Hacked-Malware-Web-Sites"},
+    {"id": "ublock_badware_risks", "name": "uBlock₀ filters – Badware risks", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_50.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://github.com/uBlockOrigin/uAssets"},
+    {"id": "urlhaus_filter_online", "name": "Malicious URL Blocklist (URLHaus)", "desc": "Lists designed specifically to block malicious, phishing, and scam domains", "url": "https://adguardteam.github.io/HostlistsRegistry/assets/filter_11.txt", "enabled": False, "category": "Security", "is_custom": False, "info": "https://urlhaus.abuse.ch/"}
+]

+ 0 - 0
core/__init__.py


二进制
core/__pycache__/__init__.cpython-39.pyc


二进制
core/__pycache__/adblock_manager.cpython-39.pyc


二进制
core/__pycache__/cluster_manager.cpython-39.pyc


二进制
core/__pycache__/dns_manager.cpython-39.pyc


二进制
core/__pycache__/file_manager.cpython-39.pyc


二进制
core/__pycache__/stats_manager.cpython-39.pyc


+ 127 - 0
core/adblock_manager.py

@@ -0,0 +1,127 @@
+import os
+import re
+import time
+import datetime
+import subprocess
+import urllib.request
+import json
+import config
+from core.file_manager import read_file, write_file, load_adblock_lists, load_custom_rules, load_panel_settings, save_panel_settings
+from core.dns_manager import get_zone_block
+
+def is_valid_domain(d):
+    if not d or len(d) > 253: return False
+    if '..' in d or d.startswith('.'): return False
+    # Επιτρέπει μόνο αλφαριθμητικά, παύλες και τελείες. Απορρίπτει *, |, /, ?, $ κλπ.
+    if not re.match(r'^[a-zA-Z0-9\-_.]+$', d): return False
+    if '.' not in d: return False
+    return True
+
+def generate_and_apply_rpz():
+    lists = load_adblock_lists()
+    active_lists = [l for l in lists if l.get('enabled')]
+    custom_rules = load_custom_rules()
+    
+    domains_set = set()
+    
+    try:
+        # 1. Fetch Lists
+        for lst in active_lists:
+            req = urllib.request.Request(lst['url'], headers={'User-Agent': 'Mozilla/5.0'})
+            try:
+                with urllib.request.urlopen(req, timeout=15, context=config.ssl_ctx) as response:
+                    lines = response.read().decode('utf-8', errors='ignore').splitlines()
+                    for line in lines:
+                        l = line.strip()
+                        # Αγνοούμε σχόλια και άκυρες γραμμές
+                        if not l or l.startswith(('#', '!', ':', '[', '@')): continue
+                        # Αγνοούμε URL paths (π.χ. example.com/ad.gif)
+                        if '/' in l: continue 
+                        
+                        d = ""
+                        if l.startswith('||'):
+                            d = re.split(r'[\^/\$:]', l[2:])[0]
+                        elif l.startswith(('0.0.0.0 ', '127.0.0.1 ')):
+                            parts = l.split()
+                            if len(parts) >= 2 and parts[1] not in ('localhost', 'broadcasthost', 'localhost.localdomain'):
+                                d = parts[1]
+                        elif ' ' not in l:
+                            d = re.split(r'[\^/\$:]', l)[0]
+                            
+                        d = d.strip().rstrip('.')
+                        # Strict Validation
+                        if is_valid_domain(d):
+                            domains_set.add(d)
+            except Exception as e:
+                print(f"Failed to fetch {lst['name']}: {e}")
+
+        # 2. Add Custom Deny
+        for d in custom_rules['deny']:
+            clean_d = d.strip().rstrip('.')
+            if clean_d.startswith('*.'): clean_d = clean_d[2:]
+            if is_valid_domain(clean_d):
+                domains_set.add(clean_d)
+
+        # 3. Create RPZ
+        rpz_records = []
+        
+        # ΠΡΩΤΑ τα Allowed (PASSTHRU)
+        for d in custom_rules['allowed']:
+            clean_d = d.strip().rstrip('.')
+            if clean_d.startswith('*.'): clean_d = clean_d[2:]
+            if is_valid_domain(clean_d):
+                rpz_records.append(f"{clean_d}\tCNAME\trpz-passthru.")
+                rpz_records.append(f"*.{clean_d}\tCNAME\trpz-passthru.")
+
+        # ΜΕΤΑ τα Blocked
+        for d in domains_set:
+            if d in custom_rules['allowed'] or f"*.{d}" in custom_rules['allowed']:
+                continue
+            rpz_records.append(f"{d}\tCNAME\t.")
+            rpz_records.append(f"*.{d}\tCNAME\t.")
+
+        serial = time.strftime('%Y%m%d01')
+        header = f"$TTL 60\n$ORIGIN rpz.blacklist.\n@ IN SOA localhost. root.localhost. ({serial} 3h 1h 1w 1h)\n IN NS localhost.\n"
+        content = header + "\n".join(rpz_records) + "\n"
+        
+        write_file(config.ADBLOCK_DB_PATH, content)
+        subprocess.run(["chown", "bind:bind", config.ADBLOCK_DB_PATH])
+        
+        # Ενημέρωση named.conf.local
+        conf_local = read_file(config.LOCAL_CONF)
+        if 'zone "rpz.blacklist"' not in conf_local:
+            conf_local += f'\nzone "rpz.blacklist" {{\n    type master;\n    file "{config.ADBLOCK_DB_PATH}";\n    allow-query {{ any; }};\n    allow-transfer {{ any; }};\n}};\n'
+            write_file(config.LOCAL_CONF, conf_local)
+
+        # Ενημέρωση named.conf.options
+        options = read_file(config.OPTIONS_FILE)
+        if "response-policy" not in options:
+            options = re.sub(r'(options\s*\{)', r'\1\n    response-policy { zone "rpz.blacklist"; };', options)
+            write_file(config.OPTIONS_FILE, options)
+
+        subprocess.run(["rndc", "reconfig"])
+        subprocess.run(["rndc", "reload", "rpz.blacklist"])
+        
+        s = load_panel_settings()
+        s['adblock_last_update'] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+        s['adblock_domains_count'] = len(domains_set)
+        save_panel_settings(s)
+        
+        return {"status": "success", "count": len(domains_set), "time": s['adblock_last_update']}
+    except Exception as e:
+        return {"status": "error", "message": str(e)}
+
+def disable_rpz():
+    conf_local = read_file(config.LOCAL_CONF)
+    options = read_file(config.OPTIONS_FILE)
+    
+    block = get_zone_block(conf_local, "rpz.blacklist")
+    if block:
+        conf_local = conf_local.replace(block, "")
+        write_file(config.LOCAL_CONF, conf_local)
+        
+    options = re.sub(r'\s*response-policy\s*\{\s*zone\s*"rpz\.blacklist";\s*\};\s*', '\n', options)
+    write_file(config.OPTIONS_FILE, options)
+    
+    subprocess.run(["rndc", "reconfig"])
+    return {"status": "success", "message": "AdBlock (RPZ) disabled."}

+ 50 - 0
core/cluster_manager.py

@@ -0,0 +1,50 @@
+import json
+import threading
+import urllib.request
+import config
+from core.file_manager import load_panel_settings, load_adblock_lists, load_custom_rules
+
+def broadcast_to_cluster(action, domain):
+    def task():
+        settings = load_panel_settings()
+        nodes = settings.get('cluster_nodes', [])
+        for node in nodes:
+            try:
+                url = f"{node['url'].rstrip('/')}/api/v1/cluster/zone"
+                headers = {"X-API-Key": node['api_key'], "Content-Type": "application/json"}
+                payload = {"domain": domain}
+                if action == 'add_slave':
+                    req = urllib.request.Request(url, data=json.dumps(payload).encode('utf-8'), headers=headers, method='POST')
+                elif action == 'delete_zone':
+                    req = urllib.request.Request(f"{url}/{domain}", headers=headers, method='DELETE')
+                urllib.request.urlopen(req, timeout=5, context=config.ssl_ctx)
+                print(f"Cluster Sync OK: {node['name']} ({action} {domain})")
+            except Exception as e:
+                print(f"Cluster Sync FAIL: {node['name']} -> {e}")
+    threading.Thread(target=task, daemon=True).start()
+
+def broadcast_adblock_to_cluster(action):
+    def task():
+        s = load_panel_settings()
+        lists = load_adblock_lists()
+        rules = load_custom_rules()
+        count = s.get('adblock_domains_count', 0)
+        update_time = s.get('adblock_last_update', 'Never')
+        
+        for node in s.get('cluster_nodes', []):
+            try:
+                url = f"{node['url'].rstrip('/')}/api/v1/cluster/adblock"
+                headers = {"X-API-Key": node['api_key'], "Content-Type": "application/json"}
+                payload = {
+                    "action": action, 
+                    "lists": lists, 
+                    "rules": rules, 
+                    "count": count, 
+                    "time": update_time
+                }
+                req = urllib.request.Request(url, data=json.dumps(payload).encode(), headers=headers, method='POST')
+                urllib.request.urlopen(req, timeout=10, context=config.ssl_ctx)
+                print(f"Cluster AdBlock Sync OK: {node['name']} ({action})")
+            except Exception as e:
+                print(f"Cluster AdBlock Sync FAIL: {node['name']} -> {e}")
+    threading.Thread(target=task, daemon=True).start()

+ 139 - 0
core/dns_manager.py

@@ -0,0 +1,139 @@
+import os
+import re
+import time
+import datetime
+import subprocess
+import config
+from core.file_manager import read_file, write_file
+
+def backup_zone(domain, content):
+    ts = int(time.time())
+    write_file(os.path.join(config.BACKUP_DIR, f"{domain}_{ts}.db"), content)
+    return ts
+
+def get_backups(domain):
+    if not os.path.exists(config.BACKUP_DIR): return []
+    files = [f for f in os.listdir(config.BACKUP_DIR) if f.startswith(f"{domain}_") and f.endswith(".db")]
+    backups = []
+    for f in sorted(files, reverse=True):
+        ts = f.replace(f"{domain}_", "").replace(".db", "")
+        dt = datetime.datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M:%S")
+        backups.append({"file": f, "time": dt, "ts": ts})
+    return backups
+
+def check_zone_syntax(domain, content):
+    tmp_path = os.path.join("/tmp", f"bind_check_{domain}_{int(time.time())}.db")
+    write_file(tmp_path, content)
+    res = subprocess.run(["named-checkzone", domain, tmp_path], capture_output=True, text=True)
+    os.remove(tmp_path)
+    if res.returncode == 0: return True, ""
+    else: return False, res.stdout.replace(tmp_path, 'zone_file').strip()
+
+def get_ds_records(domain):
+    try:
+        res = subprocess.run(["rndc", "dnssec", "-status", domain], capture_output=True, text=True)
+        if res.returncode == 0: return [line for line in res.stdout.split('\n') if line]
+    except: pass
+    return []
+
+def apply_template(content, domain, t_data):
+    replacements = {
+        "{DOMAIN}": domain, "{SERIAL}": time.strftime('%Y%m%d01'),
+        "{TTL}": str(t_data.get('ttl', '86400')), "{REFRESH}": str(t_data.get('refresh', '3600')),
+        "{RETRY}": str(t_data.get('retry', '1800')), "{EXPIRE}": str(t_data.get('expire', '604800'))
+    }
+    for k, v in t_data.get('custom_vars', {}).items(): replacements[f"{{{k.upper()}}}"] = str(v)
+    loops = 0
+    while loops < 10:
+        old_content = content
+        for k, v in replacements.items(): content = content.replace(k, v)
+        if old_content == content: break
+        loops += 1
+    return content
+
+def sanitize_record_name(name, domain):
+    name = name.strip()
+    if name.endswith('.'): return name
+    if domain.endswith('.in-addr.arpa'):
+        base_ip = ".".join(reversed(domain.replace('.in-addr.arpa', '').split('.')))
+        if name.startswith(base_ip + "."):
+            remainder = name[len(base_ip)+1:] 
+            rem_reversed = ".".join(reversed(remainder.split('.'))) 
+            return f"{rem_reversed}.{domain}."
+        elif re.match(r'^\d+(?:\.\d+)*$', name):
+            rem_reversed = ".".join(reversed(name.split('.')))
+            return f"{rem_reversed}.{domain}."
+    if name != '@' and not name.endswith('.'): return f"{name}.{domain}."
+    return name
+
+def get_zone_block(conf, domain):
+    parts = conf.split('zone "')
+    for p in parts[1:]:
+        if p.startswith(domain + '"'): return 'zone "' + p
+    return None
+
+def get_zone_file_path(domain):
+    conf = read_file(config.LOCAL_CONF)
+    block = get_zone_block(conf, domain)
+    if block:
+        m = re.search(r'file\s+"([^"]+)";', block)
+        if m:
+            p = m.group(1)
+            return p if p.startswith('/') else os.path.join(config.CACHE_PATH if "type slave" in block else config.BIND_PATH, p)
+    if domain == ".": return os.path.join(config.BIND_PATH, "db.root")
+    return os.path.join(config.BIND_PATH, f"db.{domain}")
+
+def parse_zone_file(path, domain=None):
+    records = {"A":[], "AAAA":[], "CNAME":[], "MX":[], "TXT":[], "NS":[], "SRV":[], "PTR":[]}
+    content = read_file(path)
+    if domain and domain != "." and ('\0' in content or 'SOA' not in content.upper()):
+        try:
+            res = subprocess.run(["named-compilezone", "-q", "-f", "raw", "-F", "text", "-o", "-", domain, path], capture_output=True, text=True)
+            if res.returncode == 0: content = res.stdout
+        except: pass
+    if not content: return records
+    
+    is_reverse = domain and domain.endswith('.in-addr.arpa')
+    for line in content.split('\n'):
+        clean = line.split(';')[0].strip()
+        if not clean or (domain != "." and 'SOA' in clean) or clean.startswith('$'): continue
+        tokens = clean.split()
+        ridx = -1
+        for i, t in enumerate(tokens):
+            if t.upper() in records: ridx = i; break
+        if ridx != -1:
+            rtype = tokens[ridx].upper()
+            name = re.sub(r'\.{2,}', '.', tokens[0])
+            if name.upper() in ['IN', '@'] or name.isdigit(): name = "@"
+            val = clean.split(tokens[ridx], 1)[1].strip() if rtype == 'TXT' else " ".join(tokens[ridx+1:])
+            
+            friendly_name = name
+            if is_reverse and name != '@':
+                clean_name = name.lower().replace('.in-addr.arpa.', '')
+                parts = clean_name.split('.')
+                if all(p.isdigit() for p in parts): friendly_name = ".".join(reversed(parts))
+                    
+            records[rtype].append({"name": name, "friendly_name": friendly_name, "value": val, "raw": line})
+    return records
+
+def increment_serial(content):
+    match = re.search(r'(\d+)\s*;\s*Serial', content)
+    if match:
+        old_serial = match.group(1)
+        new_serial = str(int(old_serial) + 1)
+        return content.replace(old_serial, new_serial)
+    return content
+
+def construct_record_value(req_form, rtype):
+    v = req_form.get('value', '').strip()
+    if not v: return ""
+    if rtype == 'TXT':
+        v = v.strip('"') 
+        return f'"{v}"'
+    if rtype in ['PTR', 'CNAME', 'NS'] and not v.endswith('.'): v = f"{v}."
+    elif rtype in ['MX', 'SRV']:
+        parts = v.split()
+        if len(parts) >= 2 and not parts[-1].endswith('.'):
+            parts[-1] = f"{parts[-1]}."
+            v = " ".join(parts)
+    return v

+ 104 - 0
core/file_manager.py

@@ -0,0 +1,104 @@
+import os
+import json
+import datetime
+import secrets
+from werkzeug.security import generate_password_hash
+import config
+
+def read_file(path):
+    try:
+        if os.path.exists(path):
+            with open(path, "r", errors='ignore') as f: return f.read()
+    except: pass
+    return ""
+
+def write_file(path, content):
+    with open(path, "w") as f: f.write(content)
+
+def load_panel_settings():
+    default = {
+        "templates": {}, "zone_owners": {}, "stats_retention": 30, 
+        "query_log_enabled": "no", "allow_transfer": ["none"], 
+        "also_notify": [], "allow_query": ["any"],
+        "api_key": secrets.token_hex(32), 
+        "server_public_ip": "",
+        "cluster_nodes": [],
+        "master_node": {},
+        "adblock_last_update": "Never",
+        "adblock_domains_count": 0
+    }
+    if os.path.exists(config.PANEL_SETTINGS):
+        try:
+            with open(config.PANEL_SETTINGS, "r") as f:
+                data = json.load(f)
+                for k, v in default.items():
+                    if k not in data: data[k] = v
+                # Έλεγχος αν υπάρχει api_key (backward compatibility)
+                if "api_key" not in json.loads(read_file(config.PANEL_SETTINGS) or "{}"):
+                    write_file(config.PANEL_SETTINGS, json.dumps(data, indent=4))
+                return data
+        except: pass
+    write_file(config.PANEL_SETTINGS, json.dumps(default, indent=4))
+    return default
+
+def save_panel_settings(data):
+    write_file(config.PANEL_SETTINGS, json.dumps(data, indent=4))
+
+def load_adblock_lists():
+    if os.path.exists(config.ADBLOCK_LISTS_FILE):
+        try:
+            with open(config.ADBLOCK_LISTS_FILE, "r") as f:
+                data = json.load(f)
+                existing_ids = [l["id"] for l in data]
+                for d_list in config.DEFAULT_ADBLOCK_LISTS:
+                    if d_list["id"] not in existing_ids:
+                        data.append(d_list)
+                return data
+        except: pass
+    write_file(config.ADBLOCK_LISTS_FILE, json.dumps(config.DEFAULT_ADBLOCK_LISTS, indent=4))
+    return config.DEFAULT_ADBLOCK_LISTS
+
+def save_adblock_lists(data):
+    write_file(config.ADBLOCK_LISTS_FILE, json.dumps(data, indent=4))
+
+def load_custom_rules():
+    if os.path.exists(config.ADBLOCK_CUSTOM_RULES_FILE):
+        try:
+            with open(config.ADBLOCK_CUSTOM_RULES_FILE, "r") as f: return json.load(f)
+        except: pass
+    return {"allowed": [], "deny": []}
+
+def save_custom_rules(data):
+    write_file(config.ADBLOCK_CUSTOM_RULES_FILE, json.dumps(data, indent=4))
+
+def load_users():
+    default_admin = {"admin": {"password": generate_password_hash("admin123"), "role": "admin", "assigned_templates": [], "view_mode": "cards"}}
+    if not os.path.exists(config.USERS_FILE):
+        save_users(default_admin)
+        return default_admin
+    try:
+        with open(config.USERS_FILE, "r") as f:
+            users = json.load(f)
+            if not users: return default_admin
+            for u in users:
+                if "view_mode" not in users[u]: users[u]["view_mode"] = "cards"
+                if "assigned_templates" not in users[u]: users[u]["assigned_templates"] = []
+            return users
+    except: return default_admin
+
+def save_users(data):
+    write_file(config.USERS_FILE, json.dumps(data, indent=4))
+
+def log_audit(user, domain, action):
+    try:
+        with open(config.AUDIT_FILE, "r") as f: log = json.load(f)
+    except: log = []
+    log.insert(0, {"time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "user": user, "domain": domain, "action": action})
+    # Κρατάμε μόνο τις τελευταίες 500 εγγραφές για να μην φουσκώσει το αρχείο
+    with open(config.AUDIT_FILE, "w") as f: json.dump(log[:500], f)
+
+def get_audit_log(domain):
+    try:
+        with open(config.AUDIT_FILE, "r") as f: log = json.load(f)
+        return [l for l in log if l['domain'] == domain]
+    except: return []

+ 93 - 0
core/stats_manager.py

@@ -0,0 +1,93 @@
+import os
+import time
+import datetime
+import re
+import json
+import threading
+import config
+from core.file_manager import load_panel_settings, write_file
+
+stats_lock = threading.Lock()
+last_file_size = 0
+
+def stats_collector():
+    global last_file_size
+    while True:
+        time.sleep(60)
+        try:
+            s = load_panel_settings()
+            if s.get('query_log_enabled') != 'yes' or not os.path.exists(config.QUERY_LOG_PATH): 
+                continue
+                
+            current_size = os.path.getsize(config.QUERY_LOG_PATH)
+            if current_size < last_file_size: 
+                last_file_size = 0
+                
+            lines = []
+            with open(config.QUERY_LOG_PATH, 'r', errors='ignore') as f:
+                f.seek(last_file_size)
+                lines = f.readlines()
+                last_file_size = f.tell()
+                
+            if not lines: continue
+            
+            now = datetime.datetime.now()
+            d_key, t_key = now.strftime("%Y-%m-%d"), now.strftime("%H:%M")
+            
+            with stats_lock:
+                st = {}
+                if os.path.exists(config.STATS_FILE):
+                    try:
+                        with open(config.STATS_FILE, "r") as f: st = json.load(f)
+                    except: pass
+                
+                if d_key not in st: 
+                    st[d_key] = {
+                        "queries": 0, "ips": {}, "tlds": {}, "timeline": {}, 
+                        "blocked_queries": 0, "blocked_domains": {}, 
+                        "blocked_clients": {}, "block_timeline": {}
+                    }
+                day = st[d_key]
+                
+                q_count_min = 0
+                b_count_min = 0
+                
+                for line in lines:
+                    line_lower = line.lower()
+                    
+                    # 1. Κανονικά Queries
+                    if 'query:' in line_lower:
+                        q_count_min += 1; day["queries"] = day.get("queries", 0) + 1
+                        ip_match = re.search(r'([0-9a-fA-F\.\:]+)#\d+', line)
+                        if ip_match:
+                            ip = ip_match.group(1); day["ips"][ip] = day["ips"].get(ip, 0) + 1
+                        q_match = re.search(r'query:\s+([^\s/]+)', line, re.I)
+                        if q_match:
+                            q_val = q_match.group(1).lower().strip('.')
+                            tld = q_val.split('.')[-1] if '.' in q_val else q_val
+                            day["tlds"][tld] = day.get("tlds", {}).get(tld, 0) + 1
+                            
+                    # 2. Blocked / RPZ Queries (Πιάνει όλα τα formats του BIND)
+                    if 'rpz' in line_lower and ('rewrite' in line_lower or 'qname' in line_lower or 'passthru' in line_lower):
+                        b_count_min += 1
+                        day["blocked_queries"] = day.get("blocked_queries", 0) + 1
+                        
+                        # Ψάχνει IP και Domain αγνοώντας τυχόν "@0x..."
+                        m_rpz = re.search(r'client\s+(?:@[^\s]+\s+)?([0-9a-fA-F\.\:]+)#\d+\s+\((.*?)\)', line)
+                        if m_rpz:
+                            b_ip = m_rpz.group(1)
+                            b_dom = m_rpz.group(2).strip('.')
+                            day.setdefault("blocked_clients", {})[b_ip] = day.get("blocked_clients", {}).get(b_ip, 0) + 1
+                            day.setdefault("blocked_domains", {})[b_dom] = day.get("blocked_domains", {}).get(b_dom, 0) + 1
+                
+                day.setdefault("timeline", {})[t_key] = {"rps": round(q_count_min / 60.0, 3), "unique_ips": len(day["ips"])}
+                day.setdefault("block_timeline", {})[t_key] = round(b_count_min / 60.0, 3)
+                
+                cutoff = (now - datetime.timedelta(days=int(s.get('stats_retention', 30)))).strftime("%Y-%m-%d")
+                st = {k: v for k, v in st.items() if k >= cutoff}
+                write_file(config.STATS_FILE, json.dumps(st))
+        except Exception as e: 
+            print(f"Stats Collector Error: {e}")
+
+def start_stats_daemon():
+    threading.Thread(target=stats_collector, daemon=True).start()

+ 146 - 0
templates/adanalytics.html

@@ -0,0 +1,146 @@
+{% extends "layout.html" %}
+{% block content %}
+<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
+
+<div class="max-w-7xl mx-auto p-4 md:p-8">
+    <div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 border-b border-gray-800 pb-6 gap-4">
+        <div>
+            <h2 class="text-3xl font-black text-white tracking-tighter italic flex items-center gap-3">
+                <svg class="w-8 h-8 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z"></path></svg>
+                Live Analytics
+            </h2>
+            <p class="text-gray-500 font-bold mt-2 text-[10px] md:text-xs uppercase tracking-widest">Real-time AdBlock & DNS Traffic Stats</p>
+        </div>
+        <div id="liveIndicator" class="flex items-center gap-2 bg-green-500/10 text-green-500 px-4 py-2 rounded-lg font-black uppercase text-[10px] tracking-widest border border-green-500/20">
+            <span class="relative flex h-3 w-3">
+              <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
+              <span class="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
+            </span>
+            Live Updates
+        </div>
+    </div>
+
+    {% if not log_enabled %}
+    <div class="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-xl mb-8 flex items-center gap-3 text-sm font-bold">
+        <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
+        <span><strong>Query Logging is disabled!</strong> Go to General Settings and enable Query Log to see analytics.</span>
+    </div>
+    {% endif %}
+
+    <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
+        <div class="bg-[#12161f] border border-gray-800 p-6 rounded-3xl shadow-xl flex flex-col justify-center relative overflow-hidden">
+            <svg class="w-24 h-24 absolute -right-6 -bottom-6 text-blue-500/5 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
+            <p class="text-[10px] font-black text-gray-500 uppercase tracking-widest mb-1">Total DNS Queries</p>
+            <p class="text-4xl font-black text-white font-mono" id="stat-total-queries">0</p>
+        </div>
+        <div class="bg-[#12161f] border border-gray-800 p-6 rounded-3xl shadow-xl flex flex-col justify-center relative overflow-hidden">
+            <svg class="w-24 h-24 absolute -right-6 -bottom-6 text-red-500/5 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
+            <p class="text-[10px] font-black text-gray-500 uppercase tracking-widest mb-1">Ads & Trackers Blocked</p>
+            <p class="text-4xl font-black text-red-500 font-mono" id="stat-total-blocked">0</p>
+        </div>
+        <div class="bg-[#12161f] border border-gray-800 p-6 rounded-3xl shadow-xl flex flex-col justify-center relative overflow-hidden">
+            <svg class="w-24 h-24 absolute -right-6 -bottom-6 text-green-500/5 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z"></path></svg>
+            <p class="text-[10px] font-black text-gray-500 uppercase tracking-widest mb-1">Block Percentage</p>
+            <p class="text-4xl font-black text-green-400 font-mono"><span id="stat-percentage">0</span>%</p>
+        </div>
+    </div>
+
+    <div class="bg-[#12161f] border border-gray-800 p-6 rounded-3xl shadow-xl mb-8">
+        <h3 class="text-xs font-black text-gray-400 uppercase tracking-widest mb-6">Traffic & Block Timeline (Last 60 Minutes)</h3>
+        <div class="h-64">
+            <canvas id="timelineChart"></canvas>
+        </div>
+    </div>
+
+    <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
+        <div class="bg-[#12161f] border border-gray-800 p-6 rounded-3xl shadow-xl">
+            <h3 class="text-xs font-black text-gray-400 uppercase tracking-widest mb-6">Top Blocked Clients (IPs)</h3>
+            <div class="h-64 flex justify-center">
+                <canvas id="clientsChart"></canvas>
+            </div>
+        </div>
+
+        <div class="bg-[#12161f] border border-gray-800 p-6 rounded-3xl shadow-xl">
+            <h3 class="text-xs font-black text-gray-400 uppercase tracking-widest mb-6">Top 10 Blocked Domains</h3>
+            <div class="h-64">
+                <canvas id="domainsChart"></canvas>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script>
+// Chart Defaults for dark theme
+Chart.defaults.color = '#6b7280';
+Chart.defaults.font.family = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace';
+
+let timelineChart, clientsChart, domainsChart;
+
+function initCharts() {
+    // 1. Timeline Chart (Line)
+    const ctxTime = document.getElementById('timelineChart').getContext('2d');
+    timelineChart = new Chart(ctxTime, {
+        type: 'line',
+        data: { labels: [], datasets: [
+            { label: 'Total Queries', data: [], borderColor: '#3b82f6', backgroundColor: '#3b82f61a', borderWidth: 2, fill: true, tension: 0.4, pointRadius: 0 },
+            { label: 'Blocked Requests', data: [], borderColor: '#ef4444', backgroundColor: '#ef44441a', borderWidth: 2, fill: true, tension: 0.4, pointRadius: 0 }
+        ]},
+        options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'top', labels: { color: '#9ca3af' } } }, scales: { y: { beginAtZero: true, grid: { color: '#1f2937' }, ticks: { color: '#6b7280' } }, x: { grid: { display: false }, ticks: { color: '#6b7280' } } } }
+    });
+
+    // 2. Clients Chart (Doughnut)
+    const ctxClients = document.getElementById('clientsChart').getContext('2d');
+    clientsChart = new Chart(ctxClients, {
+        type: 'doughnut',
+        data: { labels: [], datasets: [{ data: [], backgroundColor: ['#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16', '#22c55e', '#10b981'], borderWidth: 0, hoverOffset: 4 }] },
+        options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { color: '#9ca3af', boxWidth: 12 } } }, cutout: '70%' }
+    });
+
+    // 3. Domains Chart (Bar)
+    const ctxDomains = document.getElementById('domainsChart').getContext('2d');
+    domainsChart = new Chart(ctxDomains, {
+        type: 'bar',
+        data: { labels: [], datasets: [{ label: 'Block Count', data: [], backgroundColor: '#8b5cf6', borderRadius: 4 }] },
+        options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { beginAtZero: true, grid: { color: '#1f2937' }, ticks: { color: '#6b7280' } }, y: { grid: { display: false }, ticks: { color: '#9ca3af', font: { size: 10 } } } } }
+    });
+}
+
+function updateAnalytics() {
+    fetch('/api/adblock/stats_live')
+        .then(res => res.json())
+        .then(data => {
+            // Update Numbers
+            document.getElementById('stat-total-queries').innerText = data.total_queries.toLocaleString();
+            document.getElementById('stat-total-blocked').innerText = data.total_blocked.toLocaleString();
+            document.getElementById('stat-percentage').innerText = data.percentage;
+
+            // Prepare Timeline Data
+            const labels = Object.keys(data.timeline).slice(-60); // last 60 minutes
+            const queriesData = labels.map(l => data.timeline[l]?.rps * 60 || 0); // Convert RPS to total per min
+            const blocksData = labels.map(l => (data.block_timeline[l] * 60) || 0);
+
+            timelineChart.data.labels = labels;
+            timelineChart.data.datasets[0].data = queriesData;
+            timelineChart.data.datasets[1].data = blocksData;
+            timelineChart.update();
+
+            // Prepare Clients Data
+            clientsChart.data.labels = data.top_clients.map(c => c[0]);
+            clientsChart.data.datasets[0].data = data.top_clients.map(c => c[1]);
+            clientsChart.update();
+
+            // Prepare Domains Data
+            domainsChart.data.labels = data.top_domains.map(d => d[0]);
+            domainsChart.data.datasets[0].data = data.top_domains.map(d => d[1]);
+            domainsChart.update();
+        })
+        .catch(err => console.error("Analytics fetch error:", err));
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+    initCharts();
+    updateAnalytics();
+    setInterval(updateAnalytics, 5000); // Live update every 5 seconds
+});
+</script>
+{% endblock %}

+ 435 - 0
templates/adblock.html

@@ -0,0 +1,435 @@
+{% extends "layout.html" %}
+{% block content %}
+
+<div class="max-w-6xl mx-auto p-4 md:p-8">
+    <div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-10 border-b border-gray-800 pb-6 gap-4">
+        <div>
+            <h2 class="text-3xl font-black text-white tracking-tighter italic flex items-center gap-3">
+                <svg class="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
+                AdBlock Engine (RPZ)
+            </h2>
+            <p class="text-gray-500 font-bold mt-2 text-[10px] md:text-xs uppercase tracking-widest">Network-Wide Security & Filtering</p>
+        </div>
+        <div class="flex flex-wrap gap-3 w-full md:w-auto">
+            {% if rpz_enabled %}
+            <button onclick="disableAdBlock()" id="btnDisable" class="flex-1 md:flex-none bg-red-600/10 text-red-500 hover:bg-red-600 hover:text-white px-6 py-3 rounded-xl font-black uppercase text-[10px] tracking-widest transition border border-red-500/20 flex items-center justify-center gap-2">
+                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> Disable
+            </button>
+            {% endif %}
+            <button onclick="updateAdBlock()" id="btnUpdate" class="flex-1 md:flex-none bg-blue-600 hover:bg-blue-500 text-white px-8 py-3 rounded-xl font-black uppercase text-[10px] tracking-widest transition shadow-xl shadow-blue-900/20 flex items-center justify-center gap-2">
+                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg> Sync & Apply
+            </button>
+            <button onclick="syncClusterAdblock()" id="btnClusterSync" class="flex-1 md:flex-none bg-purple-600/20 text-purple-400 hover:bg-purple-600 hover:text-white px-6 py-3 rounded-xl font-black uppercase text-[10px] tracking-widest transition border border-purple-500/30 flex items-center justify-center gap-2">
+                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path></svg> Cluster Sync
+            </button>
+        </div>
+    </div>
+
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-10">
+        <div class="bg-[#12161f] border-2 {% if rpz_enabled %}border-green-500/30{% else %}border-gray-800{% endif %} rounded-3xl p-8 flex flex-col items-center justify-center text-center transition-colors relative overflow-hidden group shadow-2xl">
+            {% if rpz_enabled %}
+                <div class="absolute inset-0 bg-green-500/5 opacity-0 group-hover:opacity-100 transition duration-500"></div>
+                <div class="w-20 h-20 bg-green-500/20 text-green-500 rounded-full flex items-center justify-center mb-6 shadow-[0_0_30px_rgba(34,197,94,0.3)] animate-pulse relative z-10">
+                    <svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
+                </div>
+                <h3 class="text-3xl font-black text-green-400 uppercase tracking-widest mb-2 relative z-10">Protection Active</h3>
+                <p class="text-gray-400 text-sm relative z-10">BIND RPZ is actively intercepting bad domains.</p>
+            {% else %}
+                <div class="w-20 h-20 bg-gray-800 text-gray-500 rounded-full flex items-center justify-center mb-6 relative z-10">
+                    <svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
+                </div>
+                <h3 class="text-3xl font-black text-gray-500 uppercase tracking-widest mb-2 relative z-10">Protection Offline</h3>
+                <p class="text-gray-400 text-sm relative z-10">Toggle your preferred lists below and click Apply.</p>
+            {% endif %}
+        </div>
+
+        <div class="bg-[#12161f] border border-gray-800 rounded-3xl p-8 flex flex-col justify-center shadow-2xl relative overflow-hidden">
+            <div class="mb-8 relative z-10">
+                <p class="text-xs font-black text-gray-500 uppercase tracking-widest mb-2">Domains in Blocklist</p>
+                <p class="text-6xl font-black text-white font-mono tracking-tighter" id="domainsCount">{{ "{:,}".format(settings.adblock_domains_count) }}</p>
+            </div>
+            <div class="relative z-10">
+                <p class="text-xs font-black text-gray-500 uppercase tracking-widest mb-2">Last Sync Time</p>
+                <p class="text-base font-bold text-blue-400" id="lastUpdate">{{ settings.adblock_last_update }}</p>
+            </div>
+        </div>
+    </div>
+
+    <div class="flex gap-6 border-b border-gray-800 mb-8 overflow-x-auto whitespace-nowrap scrollbar-hide">
+        <button onclick="switchAdTab('lists')" id="adTabBtn_lists" class="pb-4 text-[11px] font-black uppercase tracking-widest text-blue-500 border-b-2 border-blue-500 transition focus:outline-none">Filter Lists</button>
+        <button onclick="switchAdTab('custom')" id="adTabBtn_custom" class="pb-4 text-[11px] font-black uppercase tracking-widest text-gray-500 border-b-2 border-transparent hover:text-gray-300 transition focus:outline-none">Custom Rules (Allow/Deny)</button>
+    </div>
+
+    <div id="adSection_lists" class="ad-section">
+        <div class="bg-[#12161f] border border-gray-800 rounded-3xl p-8 shadow-2xl">
+            <div class="flex justify-between items-center mb-8 border-b border-gray-800 pb-4">
+                <h3 class="text-2xl font-black text-white uppercase italic tracking-tighter">Blocklist Sources</h3>
+                <button onclick="document.getElementById('addCustomModal').classList.remove('hidden')" class="text-[10px] bg-gray-800 hover:bg-gray-700 text-white px-4 py-2 rounded-lg font-black uppercase tracking-widest transition flex items-center gap-2">
+                    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg> Custom List
+                </button>
+            </div>
+
+            {% set categories = ['General', 'Security', 'Other', 'Regional', 'Custom'] %}
+            
+            {% for cat in categories %}
+                {% set cat_lists = adblock_lists | selectattr('category', 'equalto', cat) | list %}
+                {% if cat_lists %}
+                    <div class="mb-10 last:mb-0">
+                        <h4 class="text-xs font-black {% if cat == 'Security' %}text-red-500{% elif cat == 'General' %}text-blue-500{% elif cat == 'Regional' %}text-yellow-500{% else %}text-purple-500{% endif %} uppercase tracking-widest mb-4 flex items-center gap-2">
+                            {{ cat }} Filters
+                        </h4>
+                        
+                        <div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
+                            {% for lst in cat_lists %}
+                            <div class="flex items-center justify-between bg-[#0b0e14] p-5 rounded-2xl border border-gray-800/60 hover:border-gray-600 transition group relative overflow-hidden">
+                                <div class="flex-1 pr-4 z-10">
+                                    <h5 class="text-base font-bold text-gray-200 group-hover:text-white transition">{{ lst.name }}</h5>
+                                    <p class="text-[11px] text-gray-500 mt-1 mb-3 line-clamp-2 leading-relaxed">{{ lst.desc | default('Custom user list.') }}</p>
+                                    <div class="flex gap-3">
+                                        <a href="{{ lst.url }}" target="_blank" class="text-[9px] text-blue-500/70 hover:text-blue-400 font-mono tracking-widest truncate max-w-[200px] inline-block flex items-center gap-1"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path></svg> List source</a>
+                                        {% if lst.info %}
+                                        <a href="{{ lst.info }}" target="_blank" class="text-[9px] text-gray-500 hover:text-gray-300 font-mono tracking-widest flex items-center gap-1"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> Info</a>
+                                        {% endif %}
+                                    </div>
+                                </div>
+                                
+                                <div class="flex flex-col items-end justify-between h-full z-10 gap-3">
+                                    <label class="relative inline-flex items-center cursor-pointer">
+                                      <input type="checkbox" onchange="toggleList('{{ lst.id }}', this.checked)" class="sr-only peer" {% if lst.enabled %}checked{% endif %}>
+                                      <div class="w-11 h-6 bg-gray-800 border border-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-gray-400 peer-checked:after:bg-white after:border-gray-500 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-green-500 peer-checked:border-green-500"></div>
+                                    </label>
+                                    
+                                    {% if lst.is_custom %}
+                                        <div class="flex gap-3 mt-2">
+                                            <button onclick="openEditCustomModal('{{ lst.id }}', '{{ lst.name|escape }}', '{{ lst.desc|escape }}', '{{ lst.url|escape }}')" class="text-blue-500 hover:text-blue-400 text-[10px] uppercase font-black transition flex items-center gap-1">
+                                                <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg> Edit
+                                            </button>
+                                            <a href="/api/adblock/delete_list/{{ lst.id }}" onclick="return confirm('Delete this custom list?')" class="text-red-500 hover:text-red-400 text-[10px] uppercase font-black transition flex items-center gap-1">
+                                                <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg> Del
+                                            </a>
+                                        </div>
+                                    {% endif %}
+                                </div>
+                            </div>
+                            {% endfor %}
+                        </div>
+                    </div>
+                {% endif %}
+            {% endfor %}
+        </div>
+    </div>
+
+    <div id="adSection_custom" class="ad-section hidden">
+        <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
+            <div class="bg-[#12161f] p-8 rounded-3xl border border-gray-800 shadow-2xl">
+                <h3 class="text-xl font-black text-green-500 uppercase italic mb-6 flex items-center gap-2">
+                    <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> Allowed Domains (Whitelist)
+                </h3>
+                <form onsubmit="addRule(event, 'allowed')" class="flex gap-2 mb-6">
+                    <input type="text" id="input_allowed" required placeholder="domain.tld or *.domain.tld" class="flex-1 bg-[#0b0e14] border border-gray-800 rounded-xl px-4 py-3 text-white font-mono text-xs outline-none focus:border-green-500 transition">
+                    <button type="submit" class="bg-green-600 hover:bg-green-500 text-white px-6 rounded-xl font-black uppercase text-[10px] shadow-lg transition flex items-center justify-center min-w-[80px]">Add</button>
+                </form>
+                <div class="bg-gray-900/30 p-4 rounded-xl mb-6 text-[10px] text-gray-400 border border-gray-800">
+                    <p class="font-black text-gray-500 mb-1">EXAMPLES:</p>
+                    <p class="font-mono">google.com <span class="mx-2 opacity-30 text-white">|</span> *.youtube.com <span class="mx-2 opacity-30 text-white">|</span> api.github.com</p>
+                </div>
+                <div class="space-y-2 h-[350px] overflow-y-auto custom-scroll pr-2">
+                    {% if custom_rules.allowed %}
+                        {% for d in custom_rules.allowed %}
+                        <div class="flex justify-between items-center bg-[#0b0e14] border border-gray-800 p-3 rounded-xl">
+                            <span class="text-green-400 font-mono text-[11px]">{{ d }}</span>
+                            <div class="flex gap-4">
+                                <button onclick="editRule('{{ d }}', 'allowed')" class="text-blue-500 hover:text-blue-400 transition text-[10px] font-black uppercase flex items-center gap-1" title="Edit">
+                                    <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg> Edit
+                                </button>
+                                <button onclick="deleteRule('{{ d }}', 'allowed')" class="text-red-500 hover:text-red-400 transition text-[10px] font-black uppercase flex items-center gap-1" title="Delete">
+                                    <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg> Delete
+                                </button>
+                            </div>
+                        </div>
+                        {% endfor %}
+                    {% else %}
+                        <div class="text-center p-10 border-2 border-dashed border-gray-800 rounded-xl text-gray-600 font-bold text-[10px] uppercase tracking-widest mt-4">No allowed domains.</div>
+                    {% endif %}
+                </div>
+            </div>
+
+            <div class="bg-[#12161f] p-8 rounded-3xl border border-gray-800 shadow-2xl">
+                <h3 class="text-xl font-black text-red-500 uppercase italic mb-6 flex items-center gap-2">
+                    <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path></svg> Deny Domains (Blacklist)
+                </h3>
+                <form onsubmit="addRule(event, 'deny')" class="flex gap-2 mb-6">
+                    <input type="text" id="input_deny" required placeholder="domain.tld or *.domain.tld" class="flex-1 bg-[#0b0e14] border border-gray-800 rounded-xl px-4 py-3 text-white font-mono text-xs outline-none focus:border-red-500 transition">
+                    <button type="submit" class="bg-red-600 hover:bg-red-500 text-white px-6 rounded-xl font-black uppercase text-[10px] shadow-lg transition flex items-center justify-center min-w-[80px]">Block</button>
+                </form>
+                <div class="bg-gray-900/30 p-4 rounded-xl mb-6 text-[10px] text-gray-400 border border-gray-800">
+                    <p class="font-black text-gray-500 mb-1">EXAMPLES:</p>
+                    <p class="font-mono">tiktok.com <span class="mx-2 opacity-30 text-white">|</span> *.analytics.io <span class="mx-2 opacity-30 text-white">|</span> tracking.net</p>
+                </div>
+                <div class="space-y-2 h-[350px] overflow-y-auto custom-scroll pr-2">
+                    {% if custom_rules.deny %}
+                        {% for d in custom_rules.deny %}
+                        <div class="flex justify-between items-center bg-[#0b0e14] border border-gray-800 p-3 rounded-xl">
+                            <span class="text-red-400 font-mono text-[11px]">{{ d }}</span>
+                            <div class="flex gap-4">
+                                <button onclick="editRule('{{ d }}', 'deny')" class="text-blue-500 hover:text-blue-400 transition text-[10px] font-black uppercase flex items-center gap-1" title="Edit">
+                                    <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg> Edit
+                                </button>
+                                <button onclick="deleteRule('{{ d }}', 'deny')" class="text-red-500 hover:text-red-400 transition text-[10px] font-black uppercase flex items-center gap-1" title="Delete">
+                                    <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg> Delete
+                                </button>
+                            </div>
+                        </div>
+                        {% endfor %}
+                    {% else %}
+                        <div class="text-center p-10 border-2 border-dashed border-gray-800 rounded-xl text-gray-600 font-bold text-[10px] uppercase tracking-widest mt-4">No blocked domains.</div>
+                    {% endif %}
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div id="addCustomModal" class="hidden fixed inset-0 bg-black/90 backdrop-blur-md z-50 flex items-center justify-center p-4">
+    <div class="bg-[#12161f] border border-gray-800 w-full max-w-lg rounded-3xl p-8 shadow-2xl">
+        <h3 class="text-2xl font-black text-white mb-6 uppercase border-b border-gray-800 pb-4">Add Custom URL</h3>
+        <form action="/api/adblock/add_custom" method="POST" class="space-y-5">
+            <div>
+                <label class="block text-[10px] font-black text-gray-500 uppercase mb-2">List Name</label>
+                <input type="text" name="name" required placeholder="e.g. My Anti-Malware List" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white outline-none focus:border-blue-500 transition font-bold">
+            </div>
+            <div>
+                <label class="block text-[10px] font-black text-gray-500 uppercase mb-2">Description (Optional)</label>
+                <input type="text" name="desc" placeholder="A short description of this list..." class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white outline-none focus:border-blue-500 transition text-sm">
+            </div>
+            <div>
+                <label class="block text-[10px] font-black text-gray-500 uppercase mb-2">Direct URL (.txt)</label>
+                <input type="url" name="url" required placeholder="https://example.com/list.txt" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white font-mono text-sm outline-none focus:border-blue-500 transition">
+                <p class="text-[9px] text-gray-500 font-bold mt-2 uppercase tracking-widest">Supports Hosts or Adblock format.</p>
+            </div>
+            <div class="flex gap-4 pt-4 border-t border-gray-800">
+                <button type="button" onclick="document.getElementById('addCustomModal').classList.add('hidden')" class="flex-1 bg-gray-800 text-gray-400 hover:text-white transition font-bold py-4 rounded-xl uppercase text-xs">Cancel</button>
+                <button type="submit" class="flex-1 bg-blue-600 hover:bg-blue-500 transition text-white font-black py-4 rounded-xl uppercase text-xs shadow-xl shadow-blue-900/20">Add List</button>
+            </div>
+        </form>
+    </div>
+</div>
+
+<div id="editCustomModal" class="hidden fixed inset-0 bg-black/90 backdrop-blur-md z-50 flex items-center justify-center p-4">
+    <div class="bg-[#12161f] border border-gray-800 w-full max-w-lg rounded-3xl p-8 shadow-2xl">
+        <h3 class="text-2xl font-black text-blue-400 mb-6 uppercase border-b border-gray-800 pb-4">Edit Custom List</h3>
+        <form action="/api/adblock/edit_custom" method="POST" class="space-y-5">
+            <input type="hidden" name="list_id" id="editListId">
+            <div>
+                <label class="block text-[10px] font-black text-gray-500 uppercase mb-2">List Name</label>
+                <input type="text" name="name" id="editListName" required class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white outline-none focus:border-blue-500 transition font-bold">
+            </div>
+            <div>
+                <label class="block text-[10px] font-black text-gray-500 uppercase mb-2">Description</label>
+                <input type="text" name="desc" id="editListDesc" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white outline-none focus:border-blue-500 transition text-sm">
+            </div>
+            <div>
+                <label class="block text-[10px] font-black text-gray-500 uppercase mb-2">Direct URL (.txt)</label>
+                <input type="url" name="url" id="editListUrl" required class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white font-mono text-sm outline-none focus:border-blue-500 transition">
+            </div>
+            <div class="flex gap-4 pt-4 border-t border-gray-800">
+                <button type="button" onclick="document.getElementById('editCustomModal').classList.add('hidden')" class="flex-1 bg-gray-800 text-gray-400 hover:text-white transition font-bold py-4 rounded-xl uppercase text-xs">Cancel</button>
+                <button type="submit" class="flex-1 bg-blue-600 hover:bg-blue-500 transition text-white font-black py-4 rounded-xl uppercase text-xs shadow-xl shadow-blue-900/20">Save</button>
+            </div>
+        </form>
+    </div>
+</div>
+
+<script>
+// --- TAB SWITCHER ---
+function switchAdTab(tab) {
+    document.querySelectorAll('.ad-section').forEach(el => el.classList.add('hidden'));
+    
+    document.querySelectorAll('[id^="adTabBtn_"]').forEach(btn => {
+        btn.classList.remove('text-blue-500', 'border-blue-500');
+        btn.classList.add('text-gray-500', 'border-transparent');
+    });
+    
+    const activeSection = document.getElementById('adSection_' + tab);
+    if (activeSection) activeSection.classList.remove('hidden');
+    
+    const activeBtn = document.getElementById('adTabBtn_' + tab);
+    if (activeBtn) {
+        activeBtn.classList.remove('text-gray-500', 'border-transparent');
+        activeBtn.classList.add('text-blue-500', 'border-blue-500');
+    }
+}
+
+// Προεπιλογή: Φόρτωσε το tab των Lists
+document.addEventListener("DOMContentLoaded", () => {
+    switchAdTab('lists');
+});
+
+// Ασφαλής κλήση για το AppToast
+const showToast = (opts) => {
+    if (typeof AppToast !== 'undefined') AppToast.fire(opts);
+    else console.log(opts.title);
+};
+
+// --- AJAX DOM REFRESHER ---
+// Αυτή η συνάρτηση διαβάζει αθόρυβα τα νέα δεδομένα από τον server 
+// και ανανεώνει ΜΟΝΟ το Custom Tab, χωρίς να γίνει reload η σελίδα!
+function refreshCustomRulesAJAX() {
+    return fetch('/adblock')
+        .then(res => res.text())
+        .then(html => {
+            const parser = new DOMParser();
+            const doc = parser.parseFromString(html, 'text/html');
+            const newCustomSection = doc.getElementById('adSection_custom').innerHTML;
+            document.getElementById('adSection_custom').innerHTML = newCustomSection;
+            
+            // Ενημερώνουμε και τον γενικό μετρητή των domains αν υπάρχει
+            const newDomainsCount = doc.getElementById('domainsCount');
+            if (newDomainsCount && document.getElementById('domainsCount')) {
+                document.getElementById('domainsCount').innerText = newDomainsCount.innerText;
+            }
+        });
+}
+
+
+// --- ADBLOCK LOGIC ---
+function toggleList(id, state) {
+    fetch('/api/adblock/toggle_list', {
+        method: 'POST',
+        headers: {'Content-Type': 'application/json'},
+        body: JSON.stringify({id: id, state: state})
+    });
+}
+
+function openEditCustomModal(id, name, desc, url) {
+    document.getElementById('editListId').value = id;
+    document.getElementById('editListName').value = name;
+    document.getElementById('editListDesc').value = desc;
+    document.getElementById('editListUrl').value = url;
+    document.getElementById('editCustomModal').classList.remove('hidden');
+}
+
+function updateAdBlock() {
+    const btn = document.getElementById('btnUpdate');
+    const originalText = btn.innerHTML;
+    btn.innerHTML = '<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg> Compiling...';
+    btn.disabled = true;
+    btn.classList.add('opacity-50', 'cursor-not-allowed');
+
+    fetch('/api/adblock/update', { method: 'POST' })
+        .then(r => r.json())
+        .then(data => {
+            if(data.status === 'success') {
+                showToast({ icon: 'success', title: `Synced ${data.count} unique domains!` });
+                setTimeout(() => window.location.reload(), 1500); // Εδώ το reload έχει νόημα γιατί ανανεώνει όλο το panel
+            } else {
+                showToast({ icon: 'error', title: 'Error', text: data.message });
+                btn.innerHTML = originalText;
+                btn.disabled = false;
+                btn.classList.remove('opacity-50', 'cursor-not-allowed');
+            }
+        }).catch(err => {
+            showToast({ icon: 'error', title: 'Connection Error' });
+            btn.innerHTML = originalText;
+            btn.disabled = false;
+            btn.classList.remove('opacity-50', 'cursor-not-allowed');
+        });
+}
+
+function disableAdBlock() {
+    if(!confirm("Disable AdBlock Engine? Your network will no longer be protected from ads/trackers.")) return;
+    const btn = document.getElementById('btnDisable');
+    btn.innerHTML = '<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg> Disabling...';
+    btn.disabled = true;
+    fetch('/api/adblock/disable', { method: 'POST' })
+        .then(r => r.json())
+        .then(data => {
+            if(data.status === 'success') {
+                showToast({ icon: 'success', title: 'AdBlock Disabled' });
+                setTimeout(() => window.location.reload(), 1000);
+            }
+        });
+}
+
+function syncClusterAdblock() {
+    const btn = document.getElementById('btnClusterSync');
+    const originalText = btn.innerHTML;
+    btn.innerHTML = '<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg> Syncing...';
+    btn.disabled = true;
+    
+    fetch('/api/adblock/sync_cluster', { method: 'POST' })
+        .then(r => r.json())
+        .then(data => {
+            showToast({ icon: 'success', title: data.message });
+            btn.innerHTML = originalText;
+            btn.disabled = false;
+        }).catch(err => {
+            showToast({ icon: 'error', title: 'Cluster Sync Failed' });
+            btn.innerHTML = originalText;
+            btn.disabled = false;
+        });
+}
+
+// --- PURE AJAX CRUD FOR CUSTOM RULES ---
+function addRule(e, type) {
+    e.preventDefault();
+    const input = document.getElementById('input_' + type);
+    const domain = input.value.trim();
+    if(!domain) return;
+    
+    const btn = e.target.querySelector('button');
+    const originalText = btn.innerText;
+    btn.innerHTML = '...';
+    btn.disabled = true;
+    
+    const fd = new FormData();
+    fd.append('type', type);
+    fd.append('domain', domain);
+    
+    fetch('/api/adblock/rules/add', { method: 'POST', body: fd })
+        .then(() => fetch('/api/adblock/update', { method: 'POST' }))
+        .then(() => refreshCustomRulesAJAX())
+        .then(() => {
+            showToast({ icon: 'success', title: `Domain added to ${type} list` });
+            // Το input καθαρίζει μόνο του επειδή γίνεται αντικατάσταση του DOM!
+        });
+}
+
+function editRule(oldDomain, type) {
+    let newDomain = prompt("Edit domain:", oldDomain);
+    if(newDomain !== null && newDomain.trim() !== "" && newDomain.trim() !== oldDomain) {
+        const fd = new FormData();
+        fd.append('type', type);
+        fd.append('old_domain', oldDomain);
+        fd.append('new_domain', newDomain.trim());
+        
+        showToast({ title: 'Updating & Syncing...', icon: 'info', timer: 2000, showConfirmButton: false });
+        
+        fetch('/api/adblock/rules/edit', { method: 'POST', body: fd })
+            .then(() => fetch('/api/adblock/update', { method: 'POST' }))
+            .then(() => refreshCustomRulesAJAX())
+            .then(() => {
+                showToast({ icon: 'success', title: 'Rule updated' });
+            });
+    }
+}
+
+function deleteRule(domain, type) {
+    if(!confirm(`Remove ${domain} from ${type} list?`)) return;
+    
+    const fd = new FormData();
+    fd.append('type', type);
+    fd.append('domain', domain);
+    
+    showToast({ title: 'Removing & Syncing...', icon: 'info', timer: 2000, showConfirmButton: false });
+    
+    fetch('/api/adblock/rules/delete', { method: 'POST', body: fd })
+        .then(() => fetch('/api/adblock/update', { method: 'POST' }))
+        .then(() => refreshCustomRulesAJAX())
+        .then(() => {
+            showToast({ icon: 'success', title: 'Rule removed' });
+        });
+}
+</script>
+
+{% endblock %}

+ 219 - 0
templates/analytics.html

@@ -0,0 +1,219 @@
+{% extends "layout.html" %}
+{% block content %}
+<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
+
+<div class="flex justify-between items-end mb-8 border-b border-gray-800 pb-6">
+    <div>
+        <h2 class="text-4xl font-black text-white tracking-tighter italic">Live Analytics Command Center</h2>
+        <p class="text-green-400 font-bold uppercase text-[10px] tracking-widest mt-2 flex items-center gap-2">
+            <span class="flex h-2 w-2 relative"><span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span><span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span></span>
+            Real-Time System Sync Enabled
+        </p>
+    </div>
+</div>
+
+<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
+    <div class="bg-[#12161f] border border-gray-800 rounded-2xl p-6 shadow-xl text-center relative overflow-hidden group hover:border-blue-500/30 transition-colors">
+        <h4 class="text-[10px] font-black text-gray-500 uppercase tracking-widest mb-2 relative z-10">Total Queries (Today)</h4>
+        <span id="statTotalQueries" class="text-4xl font-black text-blue-400 font-mono relative z-10">0</span>
+    </div>
+    <div class="bg-[#12161f] border border-gray-800 rounded-2xl p-6 shadow-xl text-center relative overflow-hidden group hover:border-green-500/30 transition-colors">
+        <h4 class="text-[10px] font-black text-gray-500 uppercase tracking-widest mb-2 relative z-10">Current RPS</h4>
+        <span id="statRps" class="text-4xl font-black text-green-400 font-mono relative z-10">0.0</span>
+    </div>
+    <div class="bg-[#12161f] border border-gray-800 rounded-2xl p-6 shadow-xl text-center relative overflow-hidden group hover:border-yellow-500/30 transition-colors">
+        <h4 class="text-[10px] font-black text-gray-500 uppercase tracking-widest mb-2 relative z-10">Peak RPS (Today)</h4>
+        <span id="statPeakRps" class="text-4xl font-black text-yellow-400 font-mono relative z-10">0.0</span>
+    </div>
+    <div class="bg-[#12161f] border border-gray-800 rounded-2xl p-6 shadow-xl text-center relative overflow-hidden group hover:border-purple-500/30 transition-colors">
+        <h4 class="text-[10px] font-black text-gray-500 uppercase tracking-widest mb-2 relative z-10">Unique Active IPs</h4>
+        <span id="statUniqueIps" class="text-4xl font-black text-purple-400 font-mono relative z-10">0</span>
+    </div>
+</div>
+
+<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
+    <div class="bg-[#12161f] border border-gray-800 rounded-2xl p-6 shadow-xl relative">
+        <div class="flex justify-between items-center mb-4"><h3 class="text-[10px] font-black text-gray-500 uppercase tracking-widest">Requests Per Second</h3></div>
+        <div class="h-40"><canvas id="rpsChart"></canvas></div>
+    </div>
+    <div class="bg-[#12161f] border border-gray-800 rounded-2xl p-6 shadow-xl relative">
+        <div class="flex justify-between items-center mb-4"><h3 class="text-[10px] font-black text-gray-500 uppercase tracking-widest">Top TLDs</h3></div>
+        <div class="h-40 flex justify-center"><canvas id="tldChart"></canvas></div>
+    </div>
+    <div class="bg-[#12161f] border border-gray-800 rounded-2xl p-6 shadow-xl relative">
+        <div class="flex justify-between items-center mb-4"><h3 class="text-[10px] font-black text-gray-500 uppercase tracking-widest">Unique IPs Timeline</h3></div>
+        <div class="h-40"><canvas id="ipsChart"></canvas></div>
+    </div>
+</div>
+
+<div class="grid grid-cols-1 xl:grid-cols-2 gap-8 mb-10">
+    
+    <div class="bg-[#12161f] border border-gray-800 rounded-2xl shadow-xl overflow-hidden flex flex-col h-[550px]">
+        <div class="p-6 border-b border-gray-800 bg-gray-900/30">
+            <h3 class="text-lg font-black text-white uppercase tracking-tighter italic">Top Client IPs</h3>
+        </div>
+        <div class="flex-1 overflow-y-auto custom-scroll" id="topIpsWrapper">
+            <table class="w-full text-left whitespace-nowrap" data-sort-bound="false">
+                <thead class="bg-black/40 text-[10px] font-black text-gray-500 uppercase tracking-widest sticky top-0 z-20 shadow-md">
+                    <tr><th class="p-4 pl-6 cursor-pointer hover:text-blue-400 group">Client IP <span class="sort-icon opacity-0 group-hover:opacity-50 text-blue-500">↕</span></th>
+                        <th class="p-4 text-right pr-6 cursor-pointer hover:text-blue-400 group">Total Queries <span class="sort-icon opacity-0 group-hover:opacity-50 text-blue-500">↕</span></th></tr>
+                </thead>
+                <tbody id="topIpsBody" class="divide-y divide-gray-800/50"></tbody>
+            </table>
+        </div>
+    </div>
+
+    <div class="bg-[#12161f] border border-gray-800 rounded-2xl shadow-xl flex flex-col h-[550px] relative">
+        <div class="p-4 border-b border-gray-800 bg-gray-900/30 flex flex-col gap-4 z-10">
+            <div class="flex justify-between items-center">
+                <h3 class="text-lg font-black text-purple-400 uppercase tracking-tighter italic">Live Query Log</h3>
+                <span id="activeIpLabel" class="text-[10px] font-black text-gray-500 bg-gray-800 px-3 py-1 rounded-full uppercase tracking-widest transition-all">Select an IP</span>
+            </div>
+            <div class="flex gap-2">
+                <button id="btnPauseTerm" onclick="toggleTerminalPause()" class="bg-gray-800 hover:bg-gray-700 text-gray-300 text-[10px] font-black uppercase px-4 py-2 rounded-lg transition border border-gray-700 w-24">Pause</button>
+                <input type="text" id="termSearch" oninput="updateTerminalFilter()" placeholder="Filter by domain or type (e.g. AAAA, google)..." class="flex-1 bg-black/50 border border-gray-700 rounded-lg px-4 text-xs text-white font-mono outline-none focus:border-purple-500 transition">
+            </div>
+        </div>
+        <div class="bg-black flex-1 p-6 font-mono text-[11px] overflow-y-auto custom-scroll flex flex-col-reverse shadow-inner relative" id="liveTerminal">
+            <div class="text-center text-gray-600 font-bold uppercase tracking-widest mt-20 flex flex-col items-center">
+                <svg class="w-12 h-12 mb-4 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 9l3 3-3 3m5 0h3M4 17h16a2 2 0 002-2V9a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
+                Click on an IP from the list<br>to view its live DNS traffic
+            </div>
+        </div>
+    </div>
+</div>
+
+<script>
+let globalStatsInterval, activeIpInterval, currentTrackingIp = null;
+let rpsChartInst = null, ipsChartInst = null, tldChartInst = null;
+let isTerminalPaused = false;
+let terminalFilterText = '';
+
+function toggleTerminalPause() {
+    isTerminalPaused = !isTerminalPaused;
+    const btn = document.getElementById('btnPauseTerm');
+    if(isTerminalPaused) {
+        btn.innerText = "Resume";
+        btn.classList.replace('bg-gray-800', 'bg-yellow-600/20');
+        btn.classList.replace('text-gray-300', 'text-yellow-500');
+        btn.classList.replace('border-gray-700', 'border-yellow-500/30');
+    } else {
+        btn.innerText = "Pause";
+        btn.classList.replace('bg-yellow-600/20', 'bg-gray-800');
+        btn.classList.replace('text-yellow-500', 'text-gray-300');
+        btn.classList.replace('border-yellow-500/30', 'border-gray-700');
+    }
+}
+
+function updateTerminalFilter() {
+    terminalFilterText = document.getElementById('termSearch').value.toLowerCase();
+}
+
+function fetchGlobalStats() {
+    fetch('/api/stats').then(r => r.json()).then(data => {
+        const today = new Date().toISOString().split('T')[0];
+        const todayData = data[today];
+        if(!todayData) return;
+
+        document.getElementById('statTotalQueries').innerText = todayData.queries.toLocaleString();
+        document.getElementById('statUniqueIps').innerText = Object.keys(todayData.ips).length.toLocaleString();
+        
+        const times = Object.keys(todayData.timeline).sort();
+        const rpsData = times.map(t => todayData.timeline[t].rps);
+        const ipsData = times.map(t => todayData.timeline[t].unique_ips);
+        
+        const lastTime = times[times.length - 1];
+        if(lastTime) document.getElementById('statRps').innerText = todayData.timeline[lastTime].rps;
+        
+        const peakRps = rpsData.length > 0 ? Math.max(...rpsData) : 0;
+        document.getElementById('statPeakRps').innerText = peakRps;
+
+        const tldLabels = Object.keys(todayData.tlds).sort((a,b) => todayData.tlds[b] - todayData.tlds[a]).slice(0, 5);
+        const tldData = tldLabels.map(t => todayData.tlds[t]);
+
+        const chartOptions = { animation: false, interaction: { mode: 'index', intersect: false }, responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false }, y: { grid: { color: '#1f2937' }, beginAtZero: true } } };
+
+        if(!rpsChartInst) {
+            rpsChartInst = new Chart(document.getElementById('rpsChart'), { type: 'line', data: { labels: times, datasets: [{ label: 'RPS', data: rpsData, borderColor: '#3b82f6', fill: true, tension: 0.4 }] }, options: chartOptions });
+        } else { rpsChartInst.data.labels = times; rpsChartInst.data.datasets[0].data = rpsData; rpsChartInst.update('none'); }
+
+        if(!ipsChartInst) {
+            ipsChartInst = new Chart(document.getElementById('ipsChart'), { type: 'bar', data: { labels: times, datasets: [{ label: 'Unique IPs', data: ipsData, backgroundColor: '#8b5cf6' }] }, options: chartOptions });
+        } else { ipsChartInst.data.labels = times; ipsChartInst.data.datasets[0].data = ipsData; ipsChartInst.update('none'); }
+
+        if(!tldChartInst) {
+            tldChartInst = new Chart(document.getElementById('tldChart'), { type: 'doughnut', data: { labels: tldLabels, datasets: [{ data: tldData, backgroundColor: ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#6366f1'], borderWidth: 0 }] }, options: { animation: false, interaction: { mode: 'index', intersect: false }, responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { color: '#9ca3af', font: { size: 10 } } } }, cutout: '70%' } });
+        } else { tldChartInst.data.labels = tldLabels; tldChartInst.data.datasets[0].data = tldData; tldChartInst.update('none'); }
+
+        const ipEntries = Object.entries(todayData.ips).sort((a,b) => b[1] - a[1]);
+        const tbody = document.getElementById('topIpsBody');
+        tbody.innerHTML = '';
+        
+        ipEntries.slice(0, 100).forEach(([ip, count]) => {
+            const tr = document.createElement('tr');
+            const isActive = (ip === currentTrackingIp);
+            tr.className = `group cursor-pointer transition-colors ${isActive ? 'bg-blue-600/10' : 'hover:bg-white/[0.02]'}`;
+            tr.onclick = () => startTrackingIp(ip);
+            tr.innerHTML = `
+                <td class="p-4 pl-6 font-mono font-bold ${isActive ? 'text-blue-400' : 'text-gray-300 group-hover:text-blue-400'} flex items-center gap-2">
+                    ${isActive ? '<span class="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse"></span>' : ''} ${ip}
+                </td>
+                <td class="p-4 pr-6 text-right font-mono text-gray-500">${count.toLocaleString()}</td>
+            `;
+            tbody.appendChild(tr);
+        });
+    }).catch(e => console.log('Analytics Sync Error:', e));
+}
+
+function startTrackingIp(ip) {
+    currentTrackingIp = ip;
+    document.getElementById('activeIpLabel').innerText = "Tracking: " + ip;
+    document.getElementById('activeIpLabel').className = "text-[10px] font-black text-blue-400 bg-blue-900/30 px-3 py-1 rounded-full uppercase tracking-widest border border-blue-500/30 shadow-[0_0_10px_rgba(59,130,246,0.5)]";
+    if(activeIpInterval) clearInterval(activeIpInterval);
+    fetchIpLogs(ip);
+    activeIpInterval = setInterval(() => fetchIpLogs(ip), 3000);
+    fetchGlobalStats();
+}
+
+function fetchIpLogs(ip) {
+    if(isTerminalPaused) return; // FIX: Αν είναι Paused, μην κάνεις update!
+
+    fetch('/api/ip_queries/' + ip).then(r => r.json()).then(data => {
+        const terminal = document.getElementById('liveTerminal');
+        
+        if(!data.queries || data.queries.length === 0) {
+            terminal.innerHTML = '<div class="text-gray-600 italic mt-auto w-full text-center">No recent queries found for this IP...</div>';
+            return;
+        }
+        
+        // APPLY SEARCH FILTER
+        let filteredQueries = data.queries;
+        if(terminalFilterText !== "") {
+            filteredQueries = filteredQueries.filter(q => q.domain.toLowerCase().includes(terminalFilterText) || q.type.toLowerCase().includes(terminalFilterText));
+        }
+
+        terminal.innerHTML = '';
+        if(filteredQueries.length === 0) {
+            terminal.innerHTML = '<div class="text-gray-600 italic mt-auto w-full text-center">No queries match your filter.</div>';
+            return;
+        }
+
+        filteredQueries.forEach(q => {
+            const div = document.createElement('div');
+            div.className = 'mb-1 border-b border-gray-900/50 last:border-0 pb-1 flex gap-3 hover:bg-white/[0.05] p-1 rounded transition-colors';
+            div.innerHTML = `
+                <span class="text-gray-500 w-24 shrink-0">${q.time}</span>
+                <span class="text-purple-400 w-10 shrink-0 font-black">${q.type}</span>
+                <span class="text-green-400 flex-1 truncate" title="${q.domain}">${q.domain}</span>
+            `;
+            terminal.appendChild(div);
+        });
+    }).catch(e => console.log('IP Log Sync Error:', e));
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+    fetchGlobalStats();
+    globalStatsInterval = setInterval(fetchGlobalStats, 10000);
+});
+</script>
+{% endblock %}

+ 214 - 0
templates/cluster.html

@@ -0,0 +1,214 @@
+{% extends "layout.html" %}
+{% block content %}
+
+<div class="max-w-7xl mx-auto p-4 md:p-8">
+    <div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-10 border-b border-gray-800 pb-6 gap-4">
+        <div>
+            <h2 class="text-3xl font-black text-white tracking-tighter italic flex items-center gap-3">
+                <svg class="w-8 h-8 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>
+                Cluster Management
+            </h2>
+            <p class="text-gray-500 font-bold mt-2 text-[10px] md:text-xs uppercase tracking-widest">Master-Slave DNS Replication</p>
+        </div>
+        <div class="flex gap-3">
+            <button onclick="syncAllZones()" class="bg-blue-600 hover:bg-blue-500 text-white px-6 py-3 rounded-xl font-black uppercase text-[10px] tracking-widest transition shadow-xl shadow-blue-900/20 flex items-center gap-2">
+                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg> Push All Zones
+            </button>
+        </div>
+    </div>
+
+    <div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
+        <div class="space-y-8">
+            
+            <div class="bg-[#12161f] border border-gray-800 rounded-3xl p-6 shadow-2xl">
+                <h3 class="text-lg font-black text-white uppercase tracking-tighter mb-4 border-b border-gray-800 pb-3">Master Configuration</h3>
+                
+                <form action="/cluster" method="POST" class="mb-6">
+                    <input type="hidden" name="action" value="save_ip">
+                    <label class="block text-[10px] font-black text-gray-500 uppercase mb-2">This Server's Public IP</label>
+                    <div class="flex gap-2">
+                        <input type="text" name="server_public_ip" value="{{ settings.server_public_ip }}" placeholder="e.g. 192.168.1.100" class="flex-1 bg-[#0b0e14] border border-gray-800 rounded-xl px-4 py-2 text-white font-mono text-sm outline-none focus:border-purple-500 transition">
+                        <button type="submit" class="bg-purple-600 hover:bg-purple-500 text-white px-4 py-2 rounded-xl font-black uppercase text-[10px] transition">Save</button>
+                    </div>
+                    <p class="text-[9px] text-gray-600 font-bold mt-2 uppercase">Used in SOA records and to allow Slave transfers.</p>
+                </form>
+
+                <div class="bg-gray-900/50 rounded-xl p-4 border border-gray-800">
+                    <p class="text-[10px] font-black text-gray-500 uppercase mb-2">Master API Key</p>
+                    <div class="flex items-center justify-between gap-4">
+                        <code class="text-xs text-green-400 break-all select-all">{{ settings.api_key }}</code>
+                        <form action="/cluster" method="POST" onsubmit="return confirm('Regenerating API Key will disconnect all current nodes. Continue?');">
+                            <input type="hidden" name="action" value="regenerate_api">
+                            <button type="submit" class="text-gray-500 hover:text-white transition" title="Regenerate API Key">
+                                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
+                            </button>
+                        </form>
+                    </div>
+                </div>
+            </div>
+
+            <div class="bg-[#12161f] border border-gray-800 rounded-3xl p-6 shadow-2xl">
+                <h3 class="text-lg font-black text-blue-400 uppercase tracking-tighter mb-4 border-b border-gray-800 pb-3">Add Slave Node</h3>
+                <form action="/cluster" method="POST" class="space-y-4">
+                    <input type="hidden" name="action" value="add_node">
+                    <div>
+                        <label class="block text-[10px] font-black text-gray-500 uppercase mb-2">Node Name</label>
+                        <input type="text" name="node_name" required placeholder="e.g. Frankfurt Slave" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl px-4 py-2 text-white text-sm outline-none focus:border-blue-500 transition">
+                    </div>
+                    <div>
+                        <label class="block text-[10px] font-black text-gray-500 uppercase mb-2">Panel URL</label>
+                        <input type="text" name="node_url" required placeholder="http://192.168.1.101:5000" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl px-4 py-2 text-white font-mono text-sm outline-none focus:border-blue-500 transition">
+                    </div>
+                    <div>
+                        <label class="block text-[10px] font-black text-gray-500 uppercase mb-2">Slave's API Key</label>
+                        <input type="text" name="node_api_key" required placeholder="Paste the API key from the slave panel..." class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl px-4 py-2 text-green-400 font-mono text-xs outline-none focus:border-blue-500 transition">
+                    </div>
+                    <button type="submit" class="w-full bg-blue-600 hover:bg-blue-500 text-white px-4 py-3 rounded-xl font-black uppercase text-[10px] tracking-widest shadow-xl transition mt-2">Connect Node</button>
+                </form>
+            </div>
+            
+        </div>
+
+        <div class="lg:col-span-2 space-y-6">
+            <h3 class="text-xl font-black text-white uppercase tracking-tighter flex items-center gap-2">
+                <svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path></svg>
+                Connected Slaves
+            </h3>
+
+            {% if settings.cluster_nodes %}
+                <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+                {% for node in settings.cluster_nodes %}
+                    <div class="bg-[#12161f] border border-gray-800 rounded-2xl p-5 shadow-xl relative group">
+                        <div class="flex justify-between items-start mb-3">
+                            <div>
+                                <h4 class="text-base font-bold text-white flex items-center gap-2">
+                                    {{ node.name }}
+                                </h4>
+                                <p class="text-xs text-gray-500 font-mono mt-1">{{ node.url }}</p>
+                            </div>
+                            <div class="flex gap-2 items-center">
+                                <span id="status_{{ loop.index0 }}" class="px-2 py-1 rounded-md text-[9px] font-black uppercase tracking-widest bg-gray-800 text-gray-400">Checking...</span>
+                            </div>
+                        </div>
+
+                        <div class="flex gap-2 mt-4 pt-4 border-t border-gray-800/50">
+                            <button onclick="controlNodeService('{{ node.name }}', 'restart')" class="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 py-2 rounded-lg text-[9px] font-black uppercase tracking-widest transition flex items-center justify-center gap-1">
+                                <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg> Restart
+                            </button>
+                            <button onclick="controlNodeService('{{ node.name }}', 'stop')" class="flex-1 bg-red-900/20 hover:bg-red-600 text-red-400 hover:text-white py-2 rounded-lg text-[9px] font-black uppercase tracking-widest transition border border-red-900/30 flex items-center justify-center gap-1">
+                                <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path></svg> Stop
+                            </button>
+                            <button onclick="controlNodeService('{{ node.name }}', 'start')" class="flex-1 bg-green-900/20 hover:bg-green-600 text-green-400 hover:text-white py-2 rounded-lg text-[9px] font-black uppercase tracking-widest transition border border-green-900/30 flex items-center justify-center gap-1">
+                                <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> Start
+                            </button>
+                        </div>
+
+                        <form action="/cluster" method="POST" class="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition" onsubmit="return confirm('Remove this node?');">
+                            <input type="hidden" name="action" value="delete_node">
+                            <input type="hidden" name="node_index" value="{{ loop.index0 }}">
+                            <button type="submit" class="text-red-500 hover:text-red-400 bg-[#12161f] p-1 rounded-md" title="Remove Node">
+                                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
+                            </button>
+                        </form>
+                    </div>
+                {% endfor %}
+                </div>
+            {% else %}
+                <div class="text-center p-12 border-2 border-dashed border-gray-800 rounded-3xl">
+                    <svg class="w-12 h-12 text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
+                    <h4 class="text-lg font-bold text-gray-400 uppercase tracking-widest mb-1">No Slaves Connected</h4>
+                    <p class="text-xs text-gray-600">Add a node from the left panel to begin replication.</p>
+                </div>
+            {% endif %}
+        </div>
+    </div>
+</div>
+
+<script>
+    // AJAX Function: Ping nodes and check status
+    function checkNodes() {
+        const nodes = [
+            {% for node in settings.cluster_nodes %}
+                {name: "{{ node.name }}", idx: {{ loop.index0 }}},
+            {% endfor %}
+        ];
+
+        nodes.forEach(node => {
+            const badge = document.getElementById(`status_${node.idx}`);
+            fetch('/cluster/check_node', {
+                method: 'POST',
+                headers: {'Content-Type': 'application/json'},
+                body: JSON.stringify({node_name: node.name})
+            })
+            .then(res => res.json())
+            .then(data => {
+                if(data.status === 'online') {
+                    badge.className = 'px-2 py-1 rounded-md text-[9px] font-black uppercase tracking-widest bg-green-500/20 text-green-400 border border-green-500/30';
+                    badge.innerText = 'Online';
+                } else if(data.status === 'auth_error') {
+                    badge.className = 'px-2 py-1 rounded-md text-[9px] font-black uppercase tracking-widest bg-yellow-500/20 text-yellow-500 border border-yellow-500/30';
+                    badge.innerText = 'Auth Error';
+                } else {
+                    badge.className = 'px-2 py-1 rounded-md text-[9px] font-black uppercase tracking-widest bg-red-500/20 text-red-500 border border-red-500/30';
+                    badge.innerText = 'Offline';
+                }
+            })
+            .catch(() => {
+                badge.className = 'px-2 py-1 rounded-md text-[9px] font-black uppercase tracking-widest bg-red-500/20 text-red-500 border border-red-500/30';
+                badge.innerText = 'Error';
+            });
+        });
+    }
+
+    // AJAX Function: Service Control (Start/Stop/Restart)
+    function controlNodeService(nodeName, action) {
+        if(!confirm(`Are you sure you want to ${action.toUpperCase()} the BIND service on ${nodeName}?`)) return;
+        
+        AppToast.fire({ icon: 'info', title: `Sending ${action} command...` });
+
+        fetch('/cluster/node_service', {
+            method: 'POST',
+            headers: {'Content-Type': 'application/json'},
+            body: JSON.stringify({node_name: nodeName, action: action})
+        })
+        .then(res => res.json())
+        .then(data => {
+            if(data.status === 'success') {
+                AppToast.fire({ icon: 'success', title: 'Success', text: data.message });
+            } else {
+                AppToast.fire({ icon: 'error', title: 'Action Failed', text: data.message });
+            }
+        })
+        .catch(err => {
+            AppToast.fire({ icon: 'error', title: 'Network Error', text: 'Could not reach the node.' });
+        });
+    }
+
+    // AJAX Function: Push All Zones
+    function syncAllZones() {
+        if(!confirm("Are you sure you want to push ALL Master zones to ALL connected Slave nodes? This might take a few moments.")) return;
+        
+        AppToast.fire({ icon: 'info', title: `Initiating Global Sync...` });
+
+        fetch('/api/cluster/sync_all', { method: 'POST' })
+        .then(res => res.json())
+        .then(data => {
+            if(data.status === 'success') {
+                AppToast.fire({ icon: 'success', title: 'Sync Started', text: data.message });
+            } else {
+                AppToast.fire({ icon: 'error', title: 'Sync Failed', text: data.message });
+            }
+        })
+        .catch(err => {
+            AppToast.fire({ icon: 'error', title: 'Error', text: 'Failed to initiate sync.' });
+        });
+    }
+
+    // Check nodes on load and every 10 seconds
+    document.addEventListener('DOMContentLoaded', () => {
+        checkNodes();
+        setInterval(checkNodes, 10000);
+    });
+</script>
+
+{% endblock %}

+ 354 - 0
templates/dashboard.html

@@ -0,0 +1,354 @@
+{% extends "layout.html" %}
+{% block content %}
+<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
+
+<div class="flex justify-between items-center mb-6">
+    <div>
+        <h2 class="text-4xl font-black text-white tracking-tighter italic">DNS Zones Dashboard</h2>
+        {% if session.get('role') == 'admin' %}
+        <div class="flex items-center gap-4 mt-2">
+            <div class="flex items-center gap-2">
+                <span id="status-dot" class="w-2.5 h-2.5 rounded-full bg-gray-600 transition-all duration-500"></span>
+                <span id="status-text" class="text-[10px] font-bold text-gray-500 uppercase tracking-widest leading-none">Checking Service...</span>
+            </div>
+            <div class="flex gap-2 border-l border-gray-800 pl-4">
+                <a href="/service/start" class="text-[9px] bg-green-600/20 text-green-400 px-3 py-1 rounded font-black border border-green-500/30">Start</a>
+                <a href="/service/stop" class="text-[9px] bg-red-600/20 text-red-400 px-3 py-1 rounded font-black border border-red-500/30">Stop</a>
+                <a href="/service/restart" class="text-[9px] bg-yellow-600/20 text-yellow-400 hover:bg-yellow-600/40 px-3 py-1 rounded font-black border border-yellow-500/30">Restart</a>
+                <a href="/service/reload" title="Reload" class="text-[9px] bg-blue-600/20 text-blue-400 px-3 py-1 rounded font-black border border-blue-500/30">Apply</a>
+            </div>
+        </div>
+        {% endif %}
+    </div>
+    
+    <div class="flex gap-3">
+        {% if session.get('role') == 'admin' %}
+        <button onclick="openBackupModal()" class="bg-gray-800 hover:bg-gray-700 border border-gray-700 px-6 py-3 rounded-xl font-black shadow-lg transition text-white uppercase text-xs tracking-widest flex items-center gap-2">
+            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>Backup
+        </button>
+        {% endif %}
+        <button onclick="openAddModal()" class="bg-blue-600 hover:bg-blue-500 px-8 py-3 rounded-xl font-black shadow-2xl transition transform hover:scale-105 text-white uppercase text-xs tracking-widest">+ New Zone</button>
+    </div>
+</div>
+
+{% if session.get('role') == 'admin' %}
+<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-10">
+    <div class="bg-[#12161f] border border-gray-800 rounded-2xl p-6 shadow-xl relative">
+        <div class="flex justify-between items-center mb-4">
+            <h3 class="text-[10px] font-black text-gray-500 uppercase tracking-widest">Requests Per Second</h3>
+            <span class="flex h-2 w-2 relative"><span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span><span class="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span></span>
+        </div>
+        <div class="h-40"><canvas id="rpsChart"></canvas></div>
+    </div>
+    <div class="bg-[#12161f] border border-gray-800 rounded-2xl p-6 shadow-xl relative">
+        <div class="flex justify-between items-center mb-4">
+            <h3 class="text-[10px] font-black text-gray-500 uppercase tracking-widest">Top TLDs</h3>
+            <span class="flex h-2 w-2 relative"><span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span><span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span></span>
+        </div>
+        <div class="h-40 flex justify-center"><canvas id="tldChart"></canvas></div>
+    </div>
+    <div class="bg-[#12161f] border border-gray-800 rounded-2xl p-6 shadow-xl relative">
+        <div class="flex justify-between items-center mb-4">
+            <h3 class="text-[10px] font-black text-gray-500 uppercase tracking-widest">Unique IPs</h3>
+            <span class="flex h-2 w-2 relative"><span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-purple-400 opacity-75"></span><span class="relative inline-flex rounded-full h-2 w-2 bg-purple-500"></span></span>
+        </div>
+        <div class="h-40"><canvas id="ipsChart"></canvas></div>
+    </div>
+</div>
+
+<script>
+let rpsChartInst = null, ipsChartInst = null, tldChartInst = null;
+
+function renderLiveCharts() {
+    fetch('/api/stats').then(r => r.json()).then(data => {
+        const today = new Date().toISOString().split('T')[0];
+        const todayData = data[today] || { timeline: {}, tlds: {} };
+        const times = Object.keys(todayData.timeline).sort();
+        const rpsData = times.map(t => todayData.timeline[t].rps);
+        const ipsData = times.map(t => todayData.timeline[t].unique_ips);
+        const tldLabels = Object.keys(todayData.tlds).sort((a,b) => todayData.tlds[b] - todayData.tlds[a]).slice(0, 5);
+        const tldData = tldLabels.map(t => todayData.tlds[t]);
+        const chartOptions = { animation: false, interaction: { mode: 'index', intersect: false }, responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false }, y: { grid: { color: '#1f2937' }, beginAtZero: true } } };
+        if(!rpsChartInst) {
+            rpsChartInst = new Chart(document.getElementById('rpsChart'), { type: 'line', data: { labels: times, datasets: [{ label: 'RPS', data: rpsData, borderColor: '#3b82f6', fill: true, tension: 0.4 }] }, options: chartOptions });
+        } else {
+            rpsChartInst.data.labels = times; rpsChartInst.data.datasets[0].data = rpsData; rpsChartInst.update('none');
+        }
+        if(!ipsChartInst) {
+            ipsChartInst = new Chart(document.getElementById('ipsChart'), { type: 'bar', data: { labels: times, datasets: [{ label: 'Unique IPs', data: ipsData, backgroundColor: '#8b5cf6' }] }, options: chartOptions });
+        } else {
+            ipsChartInst.data.labels = times; ipsChartInst.data.datasets[0].data = ipsData; ipsChartInst.update('none');
+        }
+        if(!tldChartInst) {
+            tldChartInst = new Chart(document.getElementById('tldChart'), { type: 'doughnut', data: { labels: tldLabels, datasets: [{ data: tldData, backgroundColor: ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#6366f1'], borderWidth: 0 }] }, options: { animation: false, interaction: { mode: 'index', intersect: false }, responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { color: '#9ca3af', font: { size: 10 } } } }, cutout: '70%' } });
+        } else {
+            tldChartInst.data.labels = tldLabels; tldChartInst.data.datasets[0].data = tldData; tldChartInst.update('none');
+        }
+    }).catch(e => console.log('Stats sync error:', e));
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+    renderLiveCharts();
+    setInterval(renderLiveCharts, 10000); 
+});
+</script>
+{% endif %}
+
+<div class="flex border-b border-gray-800 mb-6 gap-6 overflow-x-auto whitespace-nowrap scrollbar-hide">
+    <button onclick="switchTab('master_forward')" id="btnTab_master_forward" class="pb-4 text-[10px] font-black uppercase tracking-widest text-blue-500 border-b-2 border-blue-500 transition focus:outline-none">Master Forward</button>
+    <button onclick="switchTab('master_reverse')" id="btnTab_master_reverse" class="pb-4 text-[10px] font-black uppercase tracking-widest text-gray-500 border-b-2 border-transparent hover:text-gray-300 transition focus:outline-none">Master Reverse</button>
+    <button onclick="switchTab('slave_forward')" id="btnTab_slave_forward" class="pb-4 text-[10px] font-black uppercase tracking-widest text-gray-500 border-b-2 border-transparent hover:text-gray-300 transition focus:outline-none">Slave Forward</button>
+    <button onclick="switchTab('slave_reverse')" id="btnTab_slave_reverse" class="pb-4 text-[10px] font-black uppercase tracking-widest text-gray-500 border-b-2 border-transparent hover:text-gray-300 transition focus:outline-none">Slave Reverse</button>
+    <button onclick="switchTab('forward_zones')" id="btnTab_forward_zones" class="pb-4 text-[10px] font-black uppercase tracking-widest text-gray-500 border-b-2 border-transparent hover:text-gray-300 transition focus:outline-none">Forward Zones</button>
+</div>
+
+<div class="flex flex-col md:flex-row gap-4 mb-8">
+    <input type="text" id="zoneSearch" onkeyup="filterZones()" placeholder="Search zones..." class="flex-1 bg-[#12161f] border border-gray-800 rounded-2xl py-4 px-6 text-white font-mono outline-none focus:border-blue-500 transition text-sm">
+    <div class="flex bg-[#12161f] border border-gray-800 rounded-2xl p-1 h-[54px]">
+        <button onclick="changeViewMode('cards')" id="btnViewCards" class="px-6 rounded-xl text-xs font-black uppercase transition {% if view_mode != 'table' %}bg-blue-600 text-white shadow-lg{% else %}text-gray-500{% endif %}">Cards</button>
+        <button onclick="changeViewMode('table')" id="btnViewTable" class="px-6 rounded-xl text-xs font-black uppercase transition {% if view_mode == 'table' %}bg-blue-600 text-white shadow-lg{% else %}text-gray-500{% endif %}">Table</button>
+    </div>
+</div>
+
+<div id="dashboard-dynamic-content">
+    <div class="{% if view_mode == 'table' %}hidden{% endif %} grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6" id="zonesGrid">
+        {% for zone_name, ztype in zones %}
+        {% set url_param = 'root_zone' if zone_name == '.' else zone_name %}
+        {% set is_reverse = '.arpa' in zone_name %}
+        {% set is_master = ztype == 'master' or ztype == 'hint' %}
+        {% set is_hint = ztype == 'hint' %}
+        {% set is_forward = ztype == 'forward' %}
+        {% set tab_cat = 'forward_zones' if is_forward else ('master_forward' if is_master and not is_reverse else 'master_reverse' if is_master and is_reverse else 'slave_forward' if not is_master and not is_reverse else 'slave_reverse') %}
+        {% set friendly_name = zone_name %}
+        {% if is_reverse %}
+            {% set rev_parts = zone_name.replace('.in-addr.arpa', '').replace('.ip6.arpa', '').split('.') %}
+            {% set friendly_name = (rev_parts | reverse | join('.')) ~ '.x (Reverse)' %}
+        {% endif %}
+        <div class="zone-item zone-card bg-[#12161f] border border-gray-800 rounded-2xl p-6 shadow-xl hover:border-blue-500/40 transition group relative" data-domain="{{ zone_name|lower }}" data-owner="{{ owners.get(zone_name, 'admin')|lower }}" data-category="{{ tab_cat }}">
+            <div class="flex justify-between items-start mb-4">
+                <div class="flex gap-2 flex-wrap">
+                    <span class="text-[9px] font-black uppercase tracking-widest px-2 py-1 rounded bg-gray-900 {% if is_hint %}text-yellow-400 border border-yellow-500/20{% elif ztype=='master' %}text-green-400 border border-green-500/20{% elif ztype=='forward' %}text-cyan-400 border border-cyan-500/20{% else %}text-blue-400 border border-blue-500/20{% endif %}">{{ ztype }}</span>
+                    {% if is_hint %}<span class="text-[9px] font-black uppercase tracking-widest px-2 py-1 rounded bg-red-900/30 text-red-500">ROOT</span>{% endif %}
+                    {% if session.get('role') == 'admin' %}
+                    <span class="text-[9px] font-black text-gray-400 bg-gray-800/50 px-2 py-1 rounded">{{ owners.get(zone_name, 'admin') }}</span>
+                    <button onclick="openAssignModal('{{ zone_name }}', '{{ owners.get(zone_name, 'admin') }}')" class="text-[9px] bg-blue-600/20 text-blue-400 px-2 py-1 rounded border border-blue-500/30">Assign</button>
+                    {% endif %}
+                </div>
+                <div class="flex gap-3 opacity-0 group-hover:opacity-100 transition items-center">
+                    {% if is_master and not is_hint %}
+                    <form action="/zones/manage/{{ url_param }}/cluster_sync" method="POST" onsubmit="handleAjaxForm(event, this, 'dashboard-dynamic-content', restoreDashboardState)">
+                        <button type="submit" class="text-blue-400 text-[10px] font-black uppercase tracking-widest hover:text-blue-300 transition">Cluster Sync</button>
+                    </form>
+                    {% endif %}
+                    <a href="/zones/delete/{{ url_param }}" onclick="handleAjaxLink(event, this, 'dashboard-dynamic-content', restoreDashboardState, 'Delete this zone permanently?')" class="text-red-500 text-[10px] font-black uppercase tracking-widest hover:text-red-400 transition">Delete</a>
+                </div>
+            </div>
+            <h3 class="text-xl font-bold text-white {% if not is_reverse %}mb-6{% endif %} font-mono truncate" title="{{ zone_name }}">{% if zone_name == '.' %}. (Root Zone){% else %}{{ friendly_name }}{% endif %}</h3>
+            {% if is_reverse %}<p class="text-[9px] text-gray-500 font-mono mb-4 truncate tracking-widest">{{ zone_name }}</p>{% endif %}
+            <a href="/zones/manage/{{ url_param }}" class="block w-full text-center bg-gray-800 hover:bg-blue-600 py-3 rounded-xl text-gray-300 hover:text-white font-black text-xs transition uppercase tracking-widest">Manage</a>
+        </div>
+        {% endfor %}
+    </div>
+
+    <div id="zonesTableContainer" class="{% if view_mode != 'table' %}hidden{% endif %} bg-[#12161f] border border-gray-800 rounded-3xl overflow-x-auto shadow-2xl mb-8">
+        <table class="w-full text-left whitespace-nowrap">
+            <thead class="bg-black/20 text-[10px] font-black text-gray-500 uppercase border-b border-gray-800">
+                <tr><th class="p-6">Domain / IP Subnet</th><th class="p-6">Type</th><th class="p-6">Owner</th><th class="p-6 text-right">Actions</th></tr>
+            </thead>
+            <tbody class="divide-y divide-gray-800">
+                {% for zone_name, ztype in zones %}
+                {% set url_param = 'root_zone' if zone_name == '.' else zone_name %}
+                {% set is_reverse = '.arpa' in zone_name %}
+                {% set is_master = ztype == 'master' or ztype == 'hint' %}{% set is_hint = ztype == 'hint' %}{% set is_forward = ztype == 'forward' %}
+                {% set tab_cat = 'forward_zones' if is_forward else ('master_forward' if is_master and not is_reverse else 'master_reverse' if is_master and is_reverse else 'slave_forward' if not is_master and not is_reverse else 'slave_reverse') %}
+                {% set friendly_name = zone_name %}
+                {% if is_reverse %}{% set rev_parts = zone_name.replace('.in-addr.arpa', '').replace('.ip6.arpa', '').split('.') %}{% set friendly_name = (rev_parts | reverse | join('.')) ~ '.x (Reverse)' %}{% endif %}
+                <tr class="zone-item group" data-domain="{{ zone_name|lower }}" data-owner="{{ owners.get(zone_name, 'admin')|lower }}" data-category="{{ tab_cat }}">
+                    <td class="p-6 font-mono font-bold text-white">{% if zone_name == '.' %}. (Root Zone){% else %}{{ friendly_name }}{% endif %}{% if is_reverse %}<br><span class="text-[9px] text-gray-500 tracking-widest">{{ zone_name }}</span>{% endif %}</td>
+                    <td class="p-6"><span class="text-[9px] font-black uppercase px-2 py-1 rounded bg-gray-900 {% if is_forward %}text-cyan-400{% else %}text-blue-400{% endif %}">{{ ztype }}</span></td>
+                    <td class="p-6 text-gray-400 text-xs uppercase">{{ owners.get(zone_name, 'admin') }}</td>
+                    <td class="p-6 text-right">
+                        <div class="flex justify-end gap-4 items-center">
+                            {% if session.get('role') == 'admin' %}<button onclick="openAssignModal('{{ zone_name }}', '{{ owners.get(zone_name, 'admin') }}')" class="opacity-0 group-hover:opacity-100 text-purple-400 text-[10px] font-black uppercase transition">Assign</button>{% endif %}
+                            {% if is_master and not is_hint %}<form action="/zones/manage/{{ url_param }}/cluster_sync" method="POST" onsubmit="handleAjaxForm(event, this, 'dashboard-dynamic-content', restoreDashboardState)" class="inline-block opacity-0 group-hover:opacity-100 transition"><button type="submit" class="text-blue-400 text-[10px] font-black uppercase tracking-widest hover:text-blue-300">Cluster Sync</button></form>{% endif %}
+                            <a href="/zones/manage/{{ url_param }}" class="text-green-400 font-black text-[10px] uppercase">Manage</a>
+                            <a href="/zones/delete/{{ url_param }}" onclick="handleAjaxLink(event, this, 'dashboard-dynamic-content', restoreDashboardState, 'Delete this zone permanently?')" class="opacity-0 group-hover:opacity-100 text-red-500 text-[10px] font-black uppercase transition">Delete</a>
+                        </div>
+                    </td>
+                </tr>
+                {% endfor %}
+            </tbody>
+        </table>
+    </div>
+
+    <div id="noZonesMsg" class="hidden py-20 text-center border-2 border-dashed border-gray-800 rounded-3xl mt-6"><span class="text-gray-600 font-black uppercase text-xs">No zones found in this category</span></div>
+
+    <div id="assignZoneModal" class="hidden fixed inset-0 bg-black/90 flex items-center justify-center z-50 p-4">
+        <div class="bg-[#12161f] border border-gray-800 w-full max-w-sm rounded-3xl p-8 shadow-2xl">
+            <h3 class="text-xl font-black text-white mb-6 uppercase text-center">Assign Ownership</h3>
+            <form action="/zones/assign" method="POST" class="space-y-6" onsubmit="handleAjaxForm(event, this, 'dashboard-dynamic-content', restoreDashboardState)">
+                <input type="hidden" name="domain" id="assignDomainInput"><div class="text-center mb-4"><span id="assignDomainDisplay" class="text-lg font-mono text-blue-400 font-bold"></span></div>
+                <select name="owner" id="assignOwnerSelect" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white font-bold outline-none cursor-pointer"><option value="admin">admin (Unassign)</option>{% for u in all_usernames %}{% if u != 'admin' %}<option value="{{ u }}">{{ u }}</option>{% endif %}{% endfor %}</select>
+                <div class="flex gap-4 pt-6"><button type="button" onclick="closeAssignModal()" class="flex-1 bg-gray-800 text-gray-400 font-bold py-4 rounded-xl uppercase text-xs">Cancel</button><button type="submit" class="flex-1 bg-blue-600 text-white font-black py-4 rounded-xl uppercase text-xs shadow-xl">Assign</button></div>
+            </form>
+        </div>
+    </div>
+
+    <div id="addZoneModal" class="hidden fixed inset-0 bg-black/90 flex items-center justify-center z-50 p-4">
+        <div class="bg-[#12161f] border border-gray-800 w-full max-w-lg rounded-3xl p-8 shadow-2xl">
+            <h3 class="text-2xl font-black text-white mb-8 uppercase border-b border-gray-800 pb-4 text-center">Create New Zone</h3>
+            <form action="/zones/add" method="POST" id="addZoneForm" class="space-y-6" onsubmit="handleAjaxForm(event, this, 'dashboard-dynamic-content', restoreDashboardState)">
+                <div class="grid grid-cols-2 gap-4">
+                    <div><label class="block text-[10px] font-bold text-gray-500 uppercase mb-2 ml-1">Direction</label><select name="direction" id="zoneDirection" onchange="updatePlaceholder()" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white font-bold outline-none text-sm cursor-pointer"><option value="forward">Forward</option><option value="reverse">Reverse</option></select></div>
+                    <div><label class="block text-[10px] font-bold text-gray-500 uppercase mb-2 ml-1">Type</label><select name="type" id="zoneType" onchange="toggleZoneFields()" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white font-bold outline-none text-sm cursor-pointer"><option value="master">Master</option><option value="slave">Slave</option><option value="forward">Forwarder</option><option value="hint">Hint (Root Zone)</option></select></div>
+                </div>
+                <div id="domainContainer"><label id="domainLabel" class="block text-[10px] font-bold text-gray-500 uppercase mb-2 ml-1">Domain Name</label><input type="text" name="domain" id="domainInput" placeholder="example.com" required class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white font-mono outline-none focus:border-blue-500 transition-colors"></div>
+                <div id="masterFields" class="space-y-4"><label class="block text-[10px] font-bold text-gray-500 uppercase mb-2 ml-1">Template</label><select name="template_name" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white font-bold outline-none cursor-pointer"><option value="">-- Blank (No Template) --</option>{% for tname in templates.keys() %}<option value="{{ tname }}">{{ tname }}</option>{% endfor %}</select></div>
+                <div id="slaveFields" class="hidden space-y-4"><div class="flex justify-between items-center"><label class="text-[10px] font-bold text-gray-500 uppercase">Master IPs</label><button type="button" onclick="addDynamicIp('slaveIpCont', 'master_ips[]')" class="text-[9px] bg-purple-600/20 text-purple-400 px-3 py-1 rounded font-black uppercase">+ Add</button></div><div id="slaveIpCont" class="space-y-2"><input type="text" name="master_ips[]" placeholder="1.2.3.4" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-3 text-white font-mono"></div></div>
+                <div id="forwardFields" class="hidden space-y-4"><div class="flex justify-between items-center"><label class="text-[10px] font-bold text-gray-500 uppercase">Forwarders</label><button type="button" onclick="addDynamicIp('forwardIpCont', 'forwarders[]')" class="text-[9px] bg-blue-600/20 text-blue-400 px-3 py-1 rounded font-black uppercase">+ Add</button></div><div id="forwardIpCont" class="space-y-2"><input type="text" name="forwarders[]" placeholder="8.8.8.8" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-3 text-white font-mono"></div></div>
+                <div class="flex gap-4 pt-6 border-t border-gray-800"><button type="button" onclick="closeAddModal()" class="flex-1 bg-gray-800 text-gray-400 font-bold py-4 rounded-xl hover:text-white transition uppercase text-xs tracking-widest">Cancel</button><button type="submit" class="flex-1 bg-blue-600 text-white font-black py-4 rounded-xl shadow-xl transition uppercase text-xs tracking-widest hover:bg-blue-500">Create</button></div>
+            </form>
+        </div>
+    </div>
+
+    <div id="backupModal" class="hidden fixed inset-0 bg-black/90 flex items-center justify-center z-50 p-4">
+        <div class="bg-[#12161f] border border-gray-800 w-full max-w-md rounded-3xl p-8 shadow-2xl">
+            <h3 class="text-2xl font-black text-white mb-8 uppercase text-center italic">System Backup & Restore</h3>
+            <div class="space-y-6">
+                <div class="bg-gray-900/50 p-6 rounded-2xl border border-gray-800"><h4 class="text-xs font-black text-green-500 uppercase mb-2">Export Data</h4><a href="/backup/export" class="block w-full text-center bg-green-600/20 text-green-400 font-black py-3 rounded-xl border border-green-500/30 uppercase text-xs">Download ZIP</a></div>
+                <div class="bg-gray-900/50 p-6 rounded-2xl border border-gray-800"><h4 class="text-xs font-black text-blue-500 uppercase mb-2">Restore Data</h4><form action="/backup/import" method="POST" enctype="multipart/form-data" class="space-y-4"><input type="file" name="backup_file" required class="block w-full text-xs text-gray-400 file:bg-gray-800 file:text-white file:border-0 file:py-2 file:px-4 file:rounded-lg"><button type="submit" class="w-full bg-blue-600 text-white font-black py-3 rounded-xl uppercase text-xs shadow-xl">Restore ZIP</button></form></div>
+            </div>
+            <button onclick="closeBackupModal()" class="w-full mt-6 bg-gray-800 text-gray-400 py-3 rounded-xl uppercase text-xs font-bold">Close</button>
+        </div>
+    </div>
+</div>
+
+<script>
+async function handleAjaxForm(e, form, containerId, callback) {
+    e.preventDefault();
+    const btn = form.querySelector('button[type="submit"]');
+    if(btn) { btn.innerHTML = spinnerSvg + ' WAIT'; btn.disabled = true; }
+    try {
+        const res = await fetch(form.action, { method: form.method || 'POST', body: new FormData(form) });
+        const html = await res.text();
+        const doc = new DOMParser().parseFromString(html, 'text/html');
+        const scriptContent = Array.from(doc.querySelectorAll('script')).map(s => s.innerHTML).join('\n');
+        const errorMatch = scriptContent.match(/Toast\.fire\(\{\s*icon:\s*'error',\s*title:\s*'([^']+)'/);
+        const successMatch = scriptContent.match(/Toast\.fire\(\{\s*icon:\s*'success',\s*title:\s*'([^']+)'/);
+        const newCont = doc.getElementById(containerId);
+        if(newCont) {
+            document.getElementById(containerId).innerHTML = newCont.innerHTML;
+            if(callback) callback();
+            if(errorMatch) AppToast.fire({ icon: 'error', title: errorMatch[1] });
+            else if(successMatch) AppToast.fire({ icon: 'success', title: successMatch[1] });
+            else AppToast.fire({ icon: 'success', title: 'Action Completed!' });
+        } else window.location.reload();
+    } catch(err) { window.location.reload(); }
+}
+async function handleAjaxLink(e, link, containerId, callback, confirmMsg) {
+    e.preventDefault(); if(confirmMsg && !confirm(confirmMsg)) return;
+    try {
+        const res = await fetch(link.href);
+        const html = await res.text();
+        const doc = new DOMParser().parseFromString(html, 'text/html');
+        const scriptContent = Array.from(doc.querySelectorAll('script')).map(s => s.innerHTML).join('\n');
+        const errorMatch = scriptContent.match(/Toast\.fire\(\{\s*icon:\s*'error',\s*title:\s*'([^']+)'/);
+        const successMatch = scriptContent.match(/Toast\.fire\(\{\s*icon:\s*'success',\s*title:\s*'([^']+)'/);
+        const newCont = doc.getElementById(containerId);
+        if(newCont) {
+            document.getElementById(containerId).innerHTML = newCont.innerHTML;
+            if(callback) callback();
+            if(errorMatch) AppToast.fire({ icon: 'error', title: errorMatch[1] });
+            else if(successMatch) AppToast.fire({ icon: 'success', title: successMatch[1] });
+            else AppToast.fire({ icon: 'success', title: 'Action Executed!' });
+        } else window.location.reload();
+    } catch(err) { window.location.reload(); }
+}
+function initTableSort(container) {
+    const getCellValue = (tr, idx) => tr.children[idx].innerText.trim();
+    const comparer = (idx, asc) => (a, b) => ((v1, v2) => v1 !== '' && v2 !== '' && !isNaN(v1) && !isNaN(v2) ? v1 - v2 : v1.localeCompare(v2, undefined, {numeric: true}))(getCellValue(asc ? a : b, idx), getCellValue(asc ? b : a, idx));
+    container.querySelectorAll('table').forEach(table => {
+        if(table.dataset.sortBound) return; table.dataset.sortBound = "true";
+        table.querySelectorAll('th').forEach(th => {
+            if (th.innerText.trim().toLowerCase() === 'actions') return;
+            th.classList.add('cursor-pointer', 'hover:text-blue-400', 'transition-colors', 'select-none', 'group');
+            const originalText = th.innerHTML;
+            if(!originalText.includes('sort-icon')) { th.innerHTML = `<div class="flex items-center gap-2 justify-${th.classList.contains('text-right') ? 'end' : 'start'}">${originalText}<span class="sort-icon opacity-0 group-hover:opacity-50 transition-opacity text-blue-500 text-lg leading-none">↕</span></div>`; }
+            th.addEventListener('click', function() {
+                const tbody = table.querySelector('tbody'); if(!tbody) return;
+                table.querySelectorAll('.sort-icon').forEach(icon => { icon.innerHTML = '↕'; icon.classList.remove('opacity-100', 'text-blue-400'); icon.classList.add('opacity-0', 'text-blue-500'); });
+                this.asc = !this.asc; const icon = this.querySelector('.sort-icon'); icon.innerHTML = this.asc ? '↑' : '↓';
+                icon.classList.remove('opacity-0', 'text-blue-500', 'group-hover:opacity-50'); icon.classList.add('opacity-100', 'text-blue-400');
+                Array.from(tbody.querySelectorAll('tr')).sort(comparer(Array.from(th.parentNode.children).indexOf(th), this.asc)).forEach(tr => tbody.appendChild(tr));
+            });
+        });
+    });
+}
+function openBackupModal() { document.getElementById('backupModal').classList.remove('hidden'); }
+function closeBackupModal() { document.getElementById('backupModal').classList.add('hidden'); }
+function openAddModal() { document.getElementById('addZoneModal').classList.remove('hidden'); setTimeout(() => document.getElementById('domainInput').focus(), 100); }
+function closeAddModal() { const m = document.getElementById('addZoneModal'); if(m) m.classList.add('hidden'); }
+function openAssignModal(d, o) { document.getElementById('assignDomainInput').value=d; document.getElementById('assignDomainDisplay').innerText=d; document.getElementById('assignOwnerSelect').value=o; document.getElementById('assignZoneModal').classList.remove('hidden'); }
+function closeAssignModal() { const m = document.getElementById('assignZoneModal'); if(m) m.classList.add('hidden'); }
+function toggleZoneFields() {
+    const type = document.getElementById('zoneType').value, domCont = document.getElementById('domainContainer'), domInp = document.getElementById('domainInput');
+    if(type === 'hint') { domCont.classList.add('hidden'); domInp.required = false; domInp.value = '.'; } 
+    else { domCont.classList.remove('hidden'); domInp.required = true; if(domInp.value === '.') domInp.value = ''; }
+    document.getElementById('masterFields').style.display = (type === 'master') ? 'block' : 'none';
+    document.getElementById('slaveFields').style.display = (type === 'slave') ? 'block' : 'none';
+    document.getElementById('forwardFields').style.display = (type === 'forward') ? 'block' : 'none';
+}
+function updatePlaceholder() { 
+    const dir = document.getElementById('zoneDirection').value, inp = document.getElementById('domainInput'), lbl = document.getElementById('domainLabel');
+    if(dir === 'reverse') { lbl.innerText = 'IP SUBNET (e.g. 192.168.1)'; inp.placeholder = '192.168.1 or 10.0'; } 
+    else { lbl.innerText = 'DOMAIN NAME'; inp.placeholder = 'example.com'; }
+}
+function addDynamicIp(contId, name) { 
+    const div = document.createElement('div'); div.className = 'flex gap-2 mt-2'; 
+    div.innerHTML = `<input type="text" name="${name}" placeholder="0.0.0.0" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-3 text-white font-mono outline-none focus:border-blue-500 transition"><button type="button" onclick="this.parentElement.remove()" class="text-red-500 font-black px-4 hover:bg-red-500/10 rounded-xl transition">×</button>`; 
+    document.getElementById(contId).appendChild(div); 
+}
+function changeViewMode(mode) { document.getElementById('zonesGrid').classList.toggle('hidden', mode==='table'); document.getElementById('zonesTableContainer').classList.toggle('hidden', mode==='cards'); fetch('/settings/view_mode', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({mode: mode}) }); }
+let activeTab = 'master_forward';
+function switchTab(tab) { 
+    activeTab = tab; 
+    ['master_forward', 'master_reverse', 'slave_forward', 'slave_reverse', 'forward_zones'].forEach(t => { 
+        const btn = document.getElementById('btnTab_' + t); if(btn) btn.className = (t === activeTab) ? "pb-4 text-[10px] font-black uppercase tracking-widest text-blue-500 border-b-2 border-blue-500 transition focus:outline-none" : "pb-4 text-[10px] font-black uppercase tracking-widest text-gray-500 border-b-2 border-transparent hover:text-gray-300 transition focus:outline-none"; 
+    }); 
+    filterZones(); 
+}
+function filterZones() { 
+    let input = document.getElementById('zoneSearch').value.toLowerCase(), items = document.querySelectorAll('.zone-item'), count = 0; 
+    items.forEach(item => { 
+        let domain = item.getAttribute('data-domain'), owner = item.getAttribute('data-owner') || '', category = item.getAttribute('data-category'), match = (domain.includes(input) || owner.includes(input)) && category === activeTab; 
+        item.classList.toggle('hidden', !match); if(match) count++; 
+    }); 
+    const msg = document.getElementById('noZonesMsg'); if(msg) msg.classList.toggle('hidden', count === 0); 
+}
+function restoreDashboardState() { switchTab(activeTab); initTableSort(document.getElementById('dashboard-dynamic-content')); const searchInput = document.getElementById('zoneSearch'); if(searchInput && searchInput.value) filterZones(); updateLiveStatus(); }
+
+function updateLiveStatus() {
+    fetch('/api/service/status')
+        .then(r => r.json())
+        .then(data => {
+            const dot = document.getElementById('status-dot');
+            const text = document.getElementById('status-text');
+            if(data.status === 'active') {
+                dot.className = "w-2.5 h-2.5 rounded-full bg-green-500 shadow-[0_0_10px_rgba(34,197,94,0.8)] animate-pulse";
+                text.innerHTML = `Service: Active <span class="text-gray-600 ml-2 font-normal italic lowercase">(since ${data.uptime})</span>`;
+                text.classList.replace('text-gray-500', 'text-green-400');
+            } else {
+                dot.className = "w-2.5 h-2.5 rounded-full bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.8)]";
+                text.innerText = "Service: Offline / Crashed";
+                text.classList.replace('text-green-400', 'text-red-500');
+            }
+        })
+        .catch(() => {
+            const txt = document.getElementById('status-text');
+            if(txt) txt.innerText = "Status: Connection Error";
+        });
+}
+setInterval(updateLiveStatus, 5000);
+document.addEventListener("DOMContentLoaded", restoreDashboardState);
+</script>
+{% endblock %}

+ 14 - 0
templates/editor.html

@@ -0,0 +1,14 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="flex justify-between items-center mb-6 bg-gray-800 p-4 rounded border border-gray-700 shadow">
+    <h2 class="text-2xl font-bold">{{ title }}</h2>
+    <button onclick="document.getElementById('hid').value=editor.getValue();document.getElementById('frm').submit();" class="bg-blue-600 hover:bg-blue-500 px-8 py-2 rounded font-bold shadow-lg transition">Save Config</button>
+</div>
+<div class="border border-gray-700 rounded overflow-hidden shadow-2xl relative" style="height: 65vh;"><div id="editor" class="absolute inset-0 text-lg">{{ content }}</div></div>
+<form id="frm" action="{{ action }}" method="POST" class="hidden"><textarea name="content" id="hid"></textarea></form>
+<script>
+    var editor = ace.edit("editor");
+    editor.setTheme("ace/theme/monokai");
+    editor.session.setMode("ace/mode/perl");
+</script>
+{% endblock %}

+ 294 - 0
templates/general_config.html

@@ -0,0 +1,294 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="flex justify-between items-end mb-10 border-b border-gray-800 pb-6">
+    <div>
+        <h2 class="text-4xl font-black text-white tracking-tighter italic">Settings</h2>
+        <p class="text-gray-500 font-bold uppercase text-[10px] tracking-widest mt-2">BIND Engine & Data Retention</p>
+    </div>
+    <button onclick="saveAllSettings()" class="bg-blue-600 hover:bg-blue-500 px-10 py-3 rounded-xl font-black shadow-2xl transition transform hover:scale-[1.05] text-white uppercase tracking-widest text-xs">Save All Changes</button>
+</div>
+
+<form id="mainForm" action="/settings/save" method="POST" class="space-y-12">
+    <div class="grid grid-cols-1 xl:grid-cols-3 gap-10">
+        
+        <div class="xl:col-span-1 space-y-6">
+            <div class="bg-[#12161f] border border-gray-800 rounded-2xl p-6 shadow-xl">
+                <h3 class="text-lg font-black text-blue-500 uppercase mb-6 tracking-tighter italic border-b border-gray-800 pb-2">Global Bind Engine</h3>
+                
+                <div class="space-y-4">
+                    <div class="bg-black/20 p-4 rounded-xl border border-gray-800">
+                        <div class="flex justify-between mb-2">
+                            <label class="text-[10px] font-bold text-gray-500 uppercase">Listen IPv4 Interface</label>
+                            <button type="button" onclick="addAcl('lv4Cont', 'listen_v4[]')" class="text-blue-400 font-bold text-[9px] bg-blue-400/10 px-2 py-0.5 rounded border border-blue-400/20">+ ADD</button>
+                        </div>
+                        <div id="lv4Cont" class="space-y-2">
+                            {% for item in b_opt.listen_v4 %}<div class="flex gap-2"><input type="text" name="listen_v4[]" value="{{ item }}" class="w-full bg-[#0b0e14] border border-gray-800 rounded-lg p-2 text-xs text-white font-mono outline-none"><button type="button" onclick="this.parentElement.remove()" class="text-red-500 font-black px-3">×</button></div>{% endfor %}
+                        </div>
+                    </div>
+
+                    <div class="bg-black/20 p-4 rounded-xl border border-gray-800">
+                        <div class="flex justify-between mb-2">
+                            <label class="text-[10px] font-bold text-gray-500 uppercase">Allow Query From</label>
+                            <button type="button" onclick="addAcl('aqCont', 'allow_query[]')" class="text-blue-400 font-bold text-[9px] bg-blue-400/10 px-2 py-0.5 rounded border border-blue-400/20">+ ADD</button>
+                        </div>
+                        <div id="aqCont" class="space-y-2">
+                            {% for item in b_opt.allow_query %}<div class="flex gap-2"><input type="text" name="allow_query[]" value="{{ item }}" class="w-full bg-[#0b0e14] border border-gray-800 rounded-lg p-2 text-xs text-white font-mono outline-none"><button type="button" onclick="this.parentElement.remove()" class="text-red-500 font-black px-3">×</button></div>{% endfor %}
+                        </div>
+                    </div>
+
+                    <div class="bg-black/20 p-4 rounded-xl border border-gray-800">
+                        <div class="flex justify-between mb-2">
+                            <label class="text-[10px] font-bold text-gray-500 uppercase">Allow Transfers From</label>
+                            <button type="button" onclick="addAcl('atCont', 'allow_transfer[]')" class="text-blue-400 font-bold text-[9px] bg-blue-400/10 px-2 py-0.5 rounded border border-blue-400/20">+ ADD</button>
+                        </div>
+                        <div id="atCont" class="space-y-2">
+                            {% for item in b_opt.allow_transfer %}<div class="flex gap-2"><input type="text" name="allow_transfer[]" value="{{ item }}" class="w-full bg-[#0b0e14] border border-gray-800 rounded-lg p-2 text-xs text-white font-mono outline-none"><button type="button" onclick="this.parentElement.remove()" class="text-red-500 font-black px-3">×</button></div>{% endfor %}
+                        </div>
+                    </div>
+
+                    <div class="bg-black/20 p-4 rounded-xl border border-gray-800">
+                        <div class="flex justify-between mb-2">
+                            <label class="text-[10px] font-bold text-gray-500 uppercase">Also Notify Slaves</label>
+                            <button type="button" onclick="addAcl('anCont', 'also_notify[]')" class="text-blue-400 font-bold text-[9px] bg-blue-400/10 px-2 py-0.5 rounded border border-blue-400/20">+ ADD</button>
+                        </div>
+                        <div id="anCont" class="space-y-2">
+                            {% for item in b_opt.also_notify %}<div class="flex gap-2"><input type="text" name="also_notify[]" value="{{ item }}" class="w-full bg-[#0b0e14] border border-gray-800 rounded-lg p-2 text-xs text-white font-mono outline-none"><button type="button" onclick="this.parentElement.remove()" class="text-red-500 font-black px-3">×</button></div>{% endfor %}
+                        </div>
+                    </div>
+
+                    <div class="bg-black/20 p-4 rounded-xl border border-gray-800">
+                        <label class="text-[10px] font-bold text-gray-500 uppercase mb-2 block">DNSSEC Validation</label>
+                        <select name="dnssec" class="w-full bg-[#0b0e14] border border-gray-800 rounded-lg p-2 text-sm text-white font-bold outline-none cursor-pointer">
+                            <option value="auto" {% if b_opt.dnssec == 'auto' %}selected{% endif %}>Auto</option>
+                            <option value="yes" {% if b_opt.dnssec == 'yes' %}selected{% endif %}>Yes</option>
+                            <option value="no" {% if b_opt.dnssec == 'no' %}selected{% endif %}>No</option>
+                        </select>
+                    </div>
+
+                    <div class="bg-black/20 p-4 rounded-xl border border-gray-800">
+                        <div class="bg-black/20 p-4 rounded-xl border border-gray-800">
+                            <label class="text-[10px] font-bold text-gray-500 uppercase mb-2 block">BIND Query Logging</label>
+                            <select name="query_log_enabled" class="w-full bg-[#0b0e14] border border-gray-800 rounded-lg p-2 text-sm text-white font-bold outline-none cursor-pointer">
+                                <option value="no" {% if p_data.get('query_log_enabled') == 'no' %}selected{% endif %}>Disabled</option>
+                                <option value="yes" {% if p_data.get('query_log_enabled') == 'yes' %}selected{% endif %}>Enabled (For Analytics)</option>
+                            </select>
+                        </div><br/>
+                        <div class="bg-black/20 p-4 rounded-xl border border-gray-800">
+                            <label class="text-[10px] font-bold text-gray-500 uppercase mb-2 block">Stats Retention (Days)</label>
+                            <input type="number" name="stats_retention" value="{{ p_data.get('stats_retention', 30) }}" min="1" max="365" class="w-full bg-[#0b0e14] border border-gray-800 rounded-lg p-2 text-sm text-white font-bold outline-none">
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <div class="xl:col-span-2">
+            <div class="bg-[#12161f] border border-gray-800 rounded-2xl overflow-hidden shadow-2xl">
+                <div class="p-6 border-b border-gray-800 flex justify-between items-center bg-gray-900/30">
+                    <h3 class="text-lg font-black text-green-500 uppercase tracking-tighter italic">Zone Templates</h3>
+                    <button type="button" onclick="openAddTemplateModal()" class="bg-green-600/20 text-green-400 px-4 py-2 rounded-xl text-[10px] font-black uppercase border border-green-500/30 hover:bg-green-600/40 transition">+ New Template</button>
+                </div>
+                
+                <table class="w-full text-left">
+                    <thead class="bg-black/20 text-[10px] font-black text-gray-500 uppercase tracking-widest">
+                        <tr>
+                            <th class="p-4 pl-6">Name</th>
+                            <th class="p-4">Default TTL</th>
+                            <th class="p-4">Refresh</th>
+                            <th class="p-4 text-right pr-6">Actions</th>
+                        </tr>
+                    </thead>
+                    <tbody class="divide-y divide-gray-800" id="templatesTableBody">
+                        {% for name, t in p_data.templates.items() %}
+                        {% set idx = loop.index0 %}
+                        <tr class="hover:bg-white/[0.02] transition" id="row-{{ idx }}">
+                            <td class="p-4 pl-6 text-green-400 font-black">{{ name }}</td>
+                            <td class="p-4 text-gray-400 font-mono text-xs">{{ t.ttl }}s</td>
+                            <td class="p-4 text-gray-400 font-mono text-xs">{{ t.refresh }}s</td>
+                            <td class="p-4 text-right pr-6 whitespace-nowrap">
+                                <button type="button" onclick="editTemplate('{{ name|e }}', '{{ idx }}')" class="text-blue-400 text-[10px] font-black uppercase mr-4">Edit</button>
+                                <button type="button" onclick="this.closest('tr').remove()" class="text-red-500 text-[10px] font-black uppercase">Delete</button>
+                                
+                                <div class="hidden" id="data-container-{{ idx }}">
+                                    <input type="hidden" name="t_name[]" value="{{ name }}">
+                                    <input type="hidden" name="t_refresh[]" value="{{ t.refresh }}">
+                                    <input type="hidden" name="t_retry[]" value="{{ t.retry }}">
+                                    <input type="hidden" name="t_expire[]" value="{{ t.expire }}">
+                                    <input type="hidden" name="t_ttl[]" value="{{ t.ttl }}">
+                                    <textarea name="t_content[]" class="hidden">{{ t.content }}</textarea>
+                                    <div id="vars-storage-{{ idx }}">
+                                        {% for vk, vv in t.custom_vars.items() %}
+                                        <input type="hidden" name="custom_key_{{ idx }}[]" value="{{ vk|e }}">
+                                        <input type="hidden" name="custom_val_{{ idx }}[]" value="{{ vv|e }}">
+                                        {% endfor %}
+                                    </div>
+                                </div>
+                            </td>
+                        </tr>
+                        {% endfor %}
+                    </tbody>
+                </table>
+            </div>
+        </div>
+    </div>
+</form>
+
+<div id="templateModal" class="hidden fixed inset-0 bg-black/95 backdrop-blur-md flex items-center justify-center z-50 p-6">
+    <div class="bg-[#12161f] border border-gray-800 w-full max-w-5xl rounded-3xl p-8 shadow-2xl overflow-y-auto max-h-[90vh]">
+        <h3 id="modalTitle" class="text-2xl font-black text-white mb-8 uppercase tracking-tighter italic border-b border-gray-800 pb-4">Zone Template Editor</h3>
+        
+        <div class="grid grid-cols-1 lg:grid-cols-5 gap-6 mb-8">
+            <div class="lg:col-span-2">
+                <label class="text-[10px] font-black text-gray-500 uppercase mb-2 block ml-1">Template Label</label>
+                <input type="text" id="modal_t_name" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-green-400 font-black outline-none">
+            </div>
+            <div><label class="text-[10px] font-black text-gray-500 uppercase block mb-2">Refresh</label><input type="number" id="modal_t_refresh" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white outline-none"></div>
+            <div><label class="text-[10px] font-black text-gray-500 uppercase block mb-2">Retry</label><input type="number" id="modal_t_retry" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white outline-none"></div>
+            <div><label class="text-[10px] font-black text-gray-500 uppercase block mb-2">TTL</label><input type="number" id="modal_t_ttl" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white outline-none"></div>
+        </div>
+
+        <div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
+            <div class="bg-black/30 p-6 rounded-2xl border border-gray-800">
+                <div class="flex justify-between items-center mb-4">
+                    <span class="text-[10px] font-black text-gray-500 uppercase">Custom Variables</span>
+                    <button type="button" onclick="addModalVar('', '')" class="text-[9px] bg-gray-800 px-3 py-1 rounded-lg text-white font-bold hover:text-blue-400 transition">+ Add Var</button>
+                </div>
+                <div id="modal_vars_container" class="space-y-2"></div>
+            </div>
+            <textarea id="modal_t_content" class="w-full bg-[#0b0e14] border border-gray-800 rounded-2xl p-6 font-mono text-xs text-green-500 min-h-[300px] outline-none leading-relaxed"></textarea>
+        </div>
+
+        <div class="flex gap-4">
+            <button type="button" onclick="closeModal()" class="flex-1 bg-gray-800 text-gray-400 font-bold py-4 rounded-xl uppercase text-xs hover:text-white transition">Cancel</button>
+            <button type="button" onclick="applyToTable()" class="flex-1 bg-blue-600 text-white font-black py-4 rounded-xl uppercase text-xs hover:bg-blue-500 transition">Apply to Table</button>
+        </div>
+    </div>
+</div>
+
+<script>
+let currentEditIdx = -1;
+
+function addAcl(id, name) {
+    const cont = document.getElementById(id);
+    const div = document.createElement('div'); div.className = 'flex gap-2 mt-2';
+    div.innerHTML = `<input type="text" name="${name}" class="w-full bg-[#0b0e14] border border-gray-800 rounded-lg p-2 text-xs text-white font-mono"><button type="button" onclick="this.parentElement.remove()" class="text-red-500 font-black px-3">×</button>`;
+    cont.appendChild(div);
+}
+
+function openAddTemplateModal() {
+    currentEditIdx = -1;
+    document.getElementById('modal_t_name').value = "";
+    document.getElementById('modal_t_refresh').value = "3600";
+    document.getElementById('modal_t_retry').value = "1800";
+    document.getElementById('modal_t_ttl').value = "86400";
+    document.getElementById('modal_t_content').value = "$TTL {TTL}\n@ IN SOA ns1.{DOMAIN}. admin.{DOMAIN}. (\n {SERIAL}\n {REFRESH}\n {RETRY}\n 604800\n {TTL}\n)\n@ IN NS ns1.{DOMAIN}.";
+    document.getElementById('modal_vars_container').innerHTML = '';
+    document.getElementById('templateModal').classList.remove('hidden');
+}
+
+function editTemplate(name, idx) {
+    currentEditIdx = idx;
+    const cont = document.getElementById(`data-container-${idx}`);
+    document.getElementById('modal_t_name').value = name;
+    document.getElementById('modal_t_ttl').value = cont.querySelector('[name="t_ttl[]"]').value;
+    document.getElementById('modal_t_refresh').value = cont.querySelector('[name="t_refresh[]"]').value;
+    document.getElementById('modal_t_retry').value = cont.querySelector('[name="t_retry[]"]').value;
+    document.getElementById('modal_t_content').value = cont.querySelector('textarea[name="t_content[]"]').value;
+    
+    document.getElementById('modal_vars_container').innerHTML = '';
+    const keys = cont.querySelectorAll(`[name^="custom_key_"]`);
+    const vals = cont.querySelectorAll(`[name^="custom_val_"]`);
+    for(let i = 0; i < keys.length; i++) {
+        addModalVar(keys[i].value, vals[i].value);
+    }
+    document.getElementById('templateModal').classList.remove('hidden');
+}
+
+function addModalVar(k, v) {
+    const cont = document.getElementById('modal_vars_container');
+    const div = document.createElement('div'); div.className = 'flex gap-2 mt-2';
+    const safeK = k.replace(/"/g, '&quot;');
+    const safeV = v.replace(/"/g, '&quot;');
+    
+    div.innerHTML = `<input type="text" placeholder="KEY" value="${safeK}" class="modal-var-key w-1/3 bg-[#0b0e14] border border-gray-800 rounded-lg p-2 text-xs text-white font-bold outline-none focus:border-blue-500"><input type="text" placeholder="Value" value="${safeV}" class="modal-var-val flex-1 bg-[#0b0e14] border border-gray-800 rounded-lg p-2 text-xs text-white outline-none focus:border-blue-500"><button type="button" onclick="this.parentElement.remove()" class="text-red-500 font-black px-3">×</button>`;
+    cont.appendChild(div);
+}
+
+function closeModal() { document.getElementById('templateModal').classList.add('hidden'); }
+
+function applyToTable() {
+    const tName = document.getElementById('modal_t_name').value.trim();
+    if(!tName) return alert("Template name is required!");
+    
+    const tRefresh = document.getElementById('modal_t_refresh').value || "3600";
+    const tRetry = document.getElementById('modal_t_retry').value || "1800";
+    const tTtl = document.getElementById('modal_t_ttl').value || "86400";
+    const tContent = document.getElementById('modal_t_content').value;
+    
+    let idx = currentEditIdx;
+    if(idx === -1) {
+        idx = 'new_' + Date.now();
+    }
+    
+    let varInputs = '';
+    const mKeys = document.querySelectorAll('.modal-var-key');
+    const mVals = document.querySelectorAll('.modal-var-val');
+    for(let i=0; i<mKeys.length; i++){
+        if(mKeys[i].value.trim()){
+            const safeKey = mKeys[i].value.replace(/"/g, '&quot;');
+            const safeVal = mVals[i].value.replace(/"/g, '&quot;');
+            varInputs += `<input type="hidden" name="custom_key_${idx}[]" value="${safeKey}">
+                          <input type="hidden" name="custom_val_${idx}[]" value="${safeVal}">`;
+        }
+    }
+
+    const trHtml = `
+        <td class="p-4 pl-6 text-green-400 font-black">${tName}</td>
+        <td class="p-4 text-gray-400 font-mono text-xs">${tTtl}s</td>
+        <td class="p-4 text-gray-400 font-mono text-xs">${tRefresh}s</td>
+        <td class="p-4 text-right pr-6 whitespace-nowrap">
+            <button type="button" onclick="editTemplate('${tName}', '${idx}')" class="text-blue-400 text-[10px] font-black uppercase mr-4">Edit</button>
+            <button type="button" onclick="this.closest('tr').remove()" class="text-red-500 text-[10px] font-black uppercase">Delete</button>
+            
+            <div class="hidden" id="data-container-${idx}">
+                <input type="hidden" name="t_name[]" value="${tName}">
+                <input type="hidden" name="t_refresh[]" value="${tRefresh}">
+                <input type="hidden" name="t_retry[]" value="${tRetry}">
+                <input type="hidden" name="t_expire[]" value="604800">
+                <input type="hidden" name="t_ttl[]" value="${tTtl}">
+                <textarea name="t_content[]" class="hidden"></textarea>
+                <div id="vars-storage-${idx}">${varInputs}</div>
+            </div>
+        </td>
+    `;
+
+    let tr;
+    if(currentEditIdx === -1) {
+        tr = document.createElement('tr');
+        tr.className = "hover:bg-white/[0.02] transition";
+        tr.id = `row-${idx}`;
+        tr.innerHTML = trHtml;
+        document.getElementById('templatesTableBody').appendChild(tr);
+    } else {
+        tr = document.getElementById(`row-${idx}`);
+        tr.innerHTML = trHtml;
+    }
+    
+    tr.querySelector('textarea[name="t_content[]"]').value = tContent;
+    
+    closeModal();
+}
+
+function saveAllSettings() {
+    const containers = document.querySelectorAll('tbody tr div[id^="data-container-"]');
+    containers.forEach((cont, index) => {
+        const keys = cont.querySelectorAll('input[name^="custom_key_"]');
+        keys.forEach(k => k.name = `custom_key_${index}[]`);
+        const vals = cont.querySelectorAll('input[name^="custom_val_"]');
+        vals.forEach(v => v.name = `custom_val_${index}[]`);
+    });
+    
+    document.getElementById('mainForm').submit();
+}
+</script>
+{% endblock %}

+ 108 - 0
templates/layout.html

@@ -0,0 +1,108 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>BIND9 Pro Command Center</title>
+    <script src="https://cdn.tailwindcss.com"></script>
+    <script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
+    <style>
+        body { background-color: #0b0e14; color: #ffffff; }
+        .custom-scroll::-webkit-scrollbar { width: 6px; height: 6px; }
+        .custom-scroll::-webkit-scrollbar-track { background: transparent; }
+        .custom-scroll::-webkit-scrollbar-thumb { background: #374151; border-radius: 10px; }
+        .custom-scroll::-webkit-scrollbar-thumb:hover { background: #4b5563; }
+        /* Αποτρέπει το text selection στα διπλά κλικ */
+        .no-select { user-select: none; }
+    </style>
+</head>
+<body class="flex h-screen overflow-hidden font-sans">
+
+    <aside class="w-64 bg-[#12161f] border-r border-gray-800 flex flex-col shadow-2xl z-20">
+        <div class="p-6 border-b border-gray-800 flex items-center justify-center">
+            <h1 class="text-3xl font-black text-blue-500 italic tracking-tighter">BIND<span class="text-white">PRO</span></h1>
+        </div>
+        
+        <nav class="flex-1 p-4 space-y-2 overflow-y-auto custom-scroll mt-4">
+            <p class="px-4 text-[10px] font-black text-gray-600 uppercase tracking-widest mb-2">Main Menu</p>
+            <a href="/" class="flex items-center gap-3 px-4 py-3 text-sm font-bold text-gray-400 rounded-xl hover:bg-blue-600 hover:text-white transition group">
+                <svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg>
+                Dashboard
+            </a>
+            {% if session.get('role') == 'admin' %}
+            <a href="/analytics" class="flex items-center gap-3 px-4 py-3 text-sm font-bold text-gray-400 rounded-xl hover:bg-purple-600 hover:text-white transition group">
+                <svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg>
+                Analytics
+            </a>
+            <a href="/settings" class="flex items-center gap-3 px-4 py-3 text-sm font-bold text-gray-400 rounded-xl hover:bg-gray-700 hover:text-white transition group">
+                <svg class="w-5 h-5 group-hover:rotate-90 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
+                Settings
+            </a>
+            <a href="/users" class="flex items-center gap-3 px-4 py-3 text-sm font-bold text-gray-400 rounded-xl hover:bg-gray-700 hover:text-white transition group">
+                <svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
+                Users
+            </a>
+			<a href="/adblock" class="flex items-center gap-3 px-4 py-3 text-sm font-bold text-gray-400 rounded-xl hover:bg-gray-700 hover:text-white transition group">
+				<svg class="w-5 h-5 group-hover:scale-110 transition-transform group-hover:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+					<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.618 5.984A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016zM12 9v2m0 4h.01"></path>
+				</svg>
+				AdBlock Engine
+			</a>
+			<a href="/adanalytics" class="flex items-center gap-3 px-4 py-3 text-sm font-bold text-gray-400 rounded-xl hover:bg-gray-700 hover:text-white transition group">
+				<svg class="w-5 h-5 group-hover:scale-110 transition-transform group-hover:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+					<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
+				</svg>
+				AdBlock Analytics
+			</a>
+			<a href="/cluster" class="flex items-center gap-3 px-4 py-3 text-sm font-bold text-gray-400 rounded-xl hover:bg-blue-600 hover:text-white transition group">
+                <svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>
+                Cluster Nodes
+            </a>
+            {% endif %}
+        </nav>
+        
+        <div class="p-6 border-t border-gray-800 bg-gray-900/30">
+            <div class="flex items-center justify-between">
+                <div>
+                    <p class="text-[10px] text-gray-500 uppercase font-black tracking-widest">Logged in as</p>
+                    <p class="text-sm font-bold text-white">{{ session.get('username') }}</p>
+                </div>
+                <a href="/logout" title="Logout" class="bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white p-2 rounded-xl transition">
+                    <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg>
+                </a>
+            </div>
+        </div>
+    </aside>
+
+    <main class="flex-1 overflow-y-auto custom-scroll relative bg-[#0b0e14]">
+        <div class="p-10 max-w-[1600px] mx-auto">
+            
+            {% with messages = get_flashed_messages(with_categories=true) %}
+              {% if messages %}
+                <script>
+                  document.addEventListener('DOMContentLoaded', function() {
+                    const Toast = Swal.mixin({ toast: true, position: 'bottom-end', showConfirmButton: false, timer: 4000, timerProgressBar: true, background: '#1f2937', color: '#fff' });
+                    {% for category, message in messages %}
+                        Toast.fire({ icon: '{{ "success" if category == "success" else "error" }}', title: '{{ message }}' });
+                    {% endfor %}
+                  });
+                </script>
+              {% endif %}
+            {% endwith %}
+
+            {% block content %}{% endblock %}
+        </div>
+    </main>
+
+    <script>
+        const AppToast = Swal.mixin({
+            toast: true, position: 'bottom-end', showConfirmButton: false, 
+            timer: 3000, timerProgressBar: true, background: '#12161f', color: '#fff',
+            customClass: { popup: 'border border-gray-700 rounded-xl shadow-2xl' }
+        });
+        
+        // Loader Spinner SVG
+        const spinnerSvg = `<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white inline" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>`;
+    </script>
+</body>
+</html>

+ 23 - 0
templates/login.html

@@ -0,0 +1,23 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="min-h-[70vh] flex items-center justify-center">
+    <div class="bg-[#12161f] border border-gray-800 w-full max-w-md rounded-3xl p-10 shadow-2xl">
+        <div class="text-center mb-10">
+            <h2 class="text-4xl font-black text-white italic tracking-tighter">BIND<span class="text-blue-500">PRO</span></h2>
+            <p class="text-gray-500 font-bold uppercase text-[10px] tracking-widest mt-2">Authentication Required</p>
+        </div>
+        
+        <form method="POST" action="/login" class="space-y-6">
+            <div>
+                <label class="block text-[10px] font-black text-gray-500 uppercase mb-2 ml-1 tracking-widest">Username</label>
+                <input type="text" name="username" required class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white font-mono outline-none focus:border-blue-500 transition shadow-inner">
+            </div>
+            <div>
+                <label class="block text-[10px] font-black text-gray-500 uppercase mb-2 ml-1 tracking-widest">Password</label>
+                <input type="password" name="password" required class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white font-mono outline-none focus:border-blue-500 transition shadow-inner">
+            </div>
+            <button type="submit" class="w-full bg-blue-600 hover:bg-blue-500 text-white font-black py-4 rounded-xl shadow-xl transition transform hover:-translate-y-1 uppercase text-xs tracking-widest mt-4">Secure Sign In</button>
+        </form>
+    </div>
+</div>
+{% endblock %}

+ 583 - 0
templates/manage_zone.html

@@ -0,0 +1,583 @@
+{% extends "layout.html" %}
+{% block content %}
+
+<div id="zone-dynamic-content">
+    {% set is_reverse = '.arpa' in domain %}
+    {% set friendly_name = domain %}
+    {% set base_ip = '' %}
+    {% if is_reverse %}
+        {% set rev_parts = domain.replace('.in-addr.arpa', '').replace('.ip6.arpa', '').split('.') %}
+        {% set base_ip = rev_parts | reverse | join('.') %}
+        {% set friendly_name = base_ip ~ '.x (Reverse)' %}
+    {% endif %}
+
+    <div class="flex justify-between items-center mb-8 bg-[#12161f] p-6 rounded-2xl border border-gray-800 shadow-2xl">
+        <div>
+            <h2 class="text-3xl font-black font-mono text-blue-400 tracking-tighter">{% if domain == '.' %}. (Root Zone){% else %}{{ friendly_name }}{% endif %}</h2>
+            {% if is_reverse %}<p class="text-[10px] text-gray-500 font-mono tracking-widest mt-1">{{ domain }}</p>{% endif %}
+            
+            <div class="mt-2 flex items-center gap-3">
+                <span class="text-[10px] bg-blue-900/30 text-blue-400 px-3 py-1 rounded-full uppercase font-black border border-blue-800/50">{{ type }}</span>
+                
+                {% if type == 'slave' %}
+					<button onclick="runDigTest('{{ domain }}')" class="text-[10px] bg-green-600/20 text-green-400 px-3 py-1 rounded-full uppercase font-black border border-green-500/30 hover:bg-green-600 hover:text-white transition">
+						<i class="fa-solid fa-terminal mr-1"></i> Test Transfer (Dig)
+					</button>
+				{% endif %}
+
+                {% if type == 'slave' %}
+                    <div class="flex items-center gap-2 border-l border-gray-800 pl-3">
+                        <span class="text-[10px] text-purple-400 font-bold uppercase tracking-widest">Master DNS: {{ master_ips }}</span>
+                        <button type="button" onclick="openEditIpsModal('{{ master_ips }}')" class="text-[9px] bg-gray-800 hover:bg-gray-700 px-2 py-1 rounded-lg text-gray-300 font-bold uppercase transition">Edit IPs</button>
+                        <a href="/zones/manage/{{ 'root_zone' if domain == '.' else domain }}/convert_to_master" onclick="handleAjaxLink(event, this, 'zone-dynamic-content', restoreRecordTab, 'Convert to Master?')" class="text-[9px] bg-yellow-600/20 text-yellow-500 px-2 py-1 rounded-lg font-black uppercase transition border border-yellow-500/30">Convert to Master</a>
+                    </div>
+                {% elif type == 'master' %}
+                    <div class="flex items-center gap-2 border-l border-gray-800 pl-3">
+                        <button type="button" onclick="openConvertToSlaveModal()" class="text-[9px] bg-purple-600/20 text-purple-400 px-2 py-1 rounded-lg font-black uppercase transition border border-purple-500/30">Convert to Slave</button>
+                    </div>
+                {% elif type == 'hint' %}
+                    <span class="text-[10px] bg-red-900/30 text-red-500 px-3 py-1 rounded-full uppercase font-black border border-red-800/50">ROOT CONFIG</span>
+                {% elif type == 'forward' %}
+                    <span class="text-[10px] bg-blue-900/30 text-blue-400 px-3 py-1 rounded-full uppercase font-black border border-blue-800/50">FORWARDER</span>
+                {% endif %}
+            </div>
+        </div>
+        <a href="/" class="text-gray-500 hover:text-white font-bold text-sm transition flex items-center gap-2">← Back</a>
+    </div>
+
+    <div class="flex border-b border-gray-800 mb-6 gap-6 overflow-x-auto whitespace-nowrap scrollbar-hide">
+        <button onclick="switchMainTab('records')" id="mainTab_records" class="pb-4 text-[10px] font-black uppercase tracking-widest text-blue-500 border-b-2 border-blue-500 transition focus:outline-none">DNS Records</button>
+        {% if type == 'master' %}
+        <button onclick="switchMainTab('audit')" id="mainTab_audit" class="pb-4 text-[10px] font-black uppercase tracking-widest text-gray-500 border-b-2 border-transparent hover:text-gray-300 transition focus:outline-none">Audit & Time Machine</button>
+        <button onclick="switchMainTab('security')" id="mainTab_security" class="pb-4 text-[10px] font-black uppercase tracking-widest text-gray-500 border-b-2 border-transparent hover:text-gray-300 transition focus:outline-none">Security (DNSSEC)</button>
+        {% endif %}
+    </div>
+
+    <div id="mainSection_records" class="main-section">
+        {% if type == 'master' %}
+        <div class="bg-[#12161f] p-8 rounded-2xl border border-gray-800 mb-8 shadow-2xl">
+            <h3 class="font-black mb-6 text-green-500 uppercase text-xs tracking-widest">Add New Resource Record</h3>
+            <form action="/zones/manage/{{ 'root_zone' if domain == '.' else domain }}/add_record" method="POST" class="flex flex-col lg:flex-row gap-4 items-start" onsubmit="handleAjaxForm(event, this, 'zone-dynamic-content', () => { restoreRecordTab(); })">
+                <div class="w-full lg:w-1/4">
+                    <input type="text" name="name" id="addName" required placeholder="{% if is_reverse %}e.g. {{ base_ip }}.10{% else %}Name (@){% endif %}" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl px-4 h-[46px] text-white font-mono outline-none focus:border-blue-500 text-sm transition">
+                    <p class="text-[9px] text-gray-500 font-black tracking-widest uppercase mt-2 h-3">{% if is_reverse %}IP ADDRESS (e.g. {{ base_ip }}.x){% else %}RECORD NAME{% endif %}</p>
+                </div>
+                <div class="w-full lg:w-32">
+                    <select name="rtype" id="addRtype" onchange="updateValidation('add')" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl px-3 h-[46px] text-white font-bold outline-none cursor-pointer text-sm">
+                        {% if is_reverse %}
+                            <option value="PTR">PTR</option>
+                            <option value="NS">NS</option>
+                        {% else %}
+                            <option value="A">A</option><option value="AAAA">AAAA</option><option value="CNAME">CNAME</option><option value="MX">MX</option><option value="TXT">TXT</option><option value="NS">NS</option><option value="SRV">SRV</option>
+                        {% endif %}
+                    </select>
+                </div>
+                <div class="flex-1 w-full">
+                    <input type="text" name="value" id="addValue" required placeholder="Value" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl px-4 h-[46px] text-white font-mono outline-none focus:border-blue-500 text-sm transition">
+                    <p id="addHelper" class="text-[9px] text-gray-500 font-black tracking-widest uppercase mt-2 h-3 transition-colors"></p>
+                </div>
+                <button type="submit" class="w-full lg:w-auto bg-blue-600 hover:bg-blue-500 text-white px-10 h-[46px] rounded-xl font-black uppercase text-[11px] tracking-widest transition shadow-xl">Add</button>
+            </form>
+        </div>
+        {% elif type == 'slave' %}
+        <div class="bg-[#12161f] p-6 rounded-2xl border border-gray-800 mb-8 shadow-xl">
+            <div class="flex justify-between items-center mb-4 border-b border-gray-800 pb-4">
+                <h3 class="font-black text-purple-400 uppercase text-xs tracking-widest">Live Sync Logs</h3>
+                <a href="/zones/manage/{{ 'root_zone' if domain == '.' else domain }}/sync" onclick="handleAjaxLink(event, this, 'zone-dynamic-content', restoreRecordTab, null)" class="text-[10px] bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-lg text-white font-black uppercase transition flex items-center gap-2">
+                    <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
+                    Sync Now
+                </a>
+            </div>
+            <div class="bg-black/50 p-4 rounded-xl font-mono text-[10px] h-40 overflow-y-auto custom-scroll flex flex-col-reverse shadow-inner">
+                {% if logs %}
+                    {% for log in logs %}
+                        <div class="mb-1 text-gray-500 border-b border-gray-900 last:border-0 pb-1">{{ log }}</div>
+                    {% endfor %}
+                {% else %}
+                    <div class="text-gray-700 italic font-bold">No recent logs found or transfer pending.</div>
+                {% endif %}
+            </div>
+        </div>
+        {% endif %}
+
+        {% if type != 'forward' %}
+            {% if has_records %}
+                <div class="flex border-b border-gray-800 mb-6 gap-2 overflow-x-auto custom-scroll pb-1">
+                    {% for rtype, rlist in records.items() if rlist %}
+                        <button onclick="switchTab('{{ rtype }}')" id="btn-{{ rtype }}" class="tab-btn px-6 py-3 text-[11px] font-black uppercase tracking-widest border-b-2 transition-all flex items-center gap-2 {% if loop.first %}border-blue-500 text-blue-400{% else %}border-transparent text-gray-500 hover:text-gray-300{% endif %}">
+                            {{ rtype }} <span class="bg-gray-800/80 text-gray-400 px-2 py-0.5 rounded-full text-[9px]">{{ rlist|length }}</span>
+                        </button>
+                    {% endfor %}
+                </div>
+
+                <div class="bg-[#12161f] border border-gray-800 rounded-2xl overflow-hidden shadow-2xl min-h-[200px]">
+                    {% for rtype, rlist in records.items() if rlist %}
+                    <div id="tab-{{ rtype }}" class="tab-content {% if not loop.first %}hidden{% endif %}">
+                        <table class="w-full text-sm text-left font-mono">
+                            <thead class="bg-black/20 text-[10px] font-black text-gray-500 uppercase tracking-widest border-b border-gray-800">
+                                <tr><th class="px-6 py-4 w-1/3">Name / IP</th><th class="px-6 py-4">Value</th><th class="px-6 py-4 text-right">Actions</th></tr>
+                            </thead>
+                            <tbody class="divide-y divide-gray-800/50">
+                                {% for r in rlist %}
+                                <tr class="hover:bg-white/[0.02] group transition-colors cursor-pointer no-select" 
+                                    {% if type == 'master' %} ondblclick="openEditModal('{{r.raw|e}}', '{{r.friendly_name|e|default(r.name|e)}}', '{{rtype}}', '{{r.value|e|replace("'", "\\'")|replace("\"", "&quot;")}}')" {% endif %}>
+                                    <td class="px-6 py-4 text-blue-400 font-bold truncate max-w-[200px]" title="{{ r.name }}">{{ r.friendly_name | default(r.name) }}</td>
+                                    <td class="px-6 py-4 text-gray-400 text-xs break-all">{{ r.value }}</td>
+                                    <td class="px-6 py-4 text-right opacity-0 group-hover:opacity-100 transition whitespace-nowrap">
+                                        {% if type == 'master' %}
+                                        <button onclick="openEditModal('{{r.raw|e}}', '{{r.friendly_name|e|default(r.name|e)}}', '{{rtype}}', '{{r.value|e|replace("'", "\\'")|replace("\"", "&quot;")}}')" class="text-blue-500 hover:text-blue-400 text-[10px] font-black uppercase mr-4 transition">Edit</button>
+                                        <form action="/zones/manage/{{ 'root_zone' if domain == '.' else domain }}/delete_record" method="POST" class="inline" onsubmit="if(!confirm('Delete this record?')) return false; handleAjaxForm(event, this, 'zone-dynamic-content', restoreRecordTab)"><input type="hidden" name="raw_line" value="{{ r.raw }}"><button type="submit" class="text-red-500/50 hover:text-red-500 text-[10px] font-black uppercase transition">Delete</button></form>
+                                        {% else %}
+                                        <span class="text-gray-600 text-[9px] uppercase font-black tracking-widest italic">Read Only</span>
+                                        {% endif %}
+                                    </td>
+                                </tr>
+                                {% endfor %}
+                            </tbody>
+                        </table>
+                    </div>
+                    {% endfor %}
+                </div>
+                {% if type == 'master' %}
+                <p class="text-center text-[10px] text-gray-500 font-bold mt-4 uppercase tracking-widest animate-pulse">Tip: Double-click any row to edit instantly</p>
+                {% endif %}
+            {% else %}
+                <div class="flex flex-col items-center justify-center p-16 text-center border-2 border-dashed border-gray-800 rounded-2xl">
+                    <svg class="w-12 h-12 text-gray-700 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path></svg>
+                    <p class="text-gray-500 font-bold uppercase tracking-widest text-[11px]">No records found yet.</p>
+                </div>
+            {% endif %}
+        {% endif %}
+    </div> 
+
+    <div id="mainSection_audit" class="main-section hidden">
+        <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
+            <div class="bg-[#12161f] border border-gray-800 rounded-2xl shadow-xl flex flex-col h-[500px]">
+                <div class="p-6 border-b border-gray-800 bg-gray-900/30"><h3 class="text-lg font-black text-white uppercase tracking-tighter italic">Audit Log (Recent Changes)</h3></div>
+                <div class="flex-1 overflow-y-auto custom-scroll p-6 font-mono text-[11px]">
+                    {% if audit_logs %}
+                        {% for log in audit_logs %}
+                        <div class="mb-2 pb-2 border-b border-gray-800 last:border-0 text-gray-400">
+                            <span class="text-gray-600 block mb-1">{{ log.time }}</span>
+                            <span class="text-blue-400 font-bold">{{ log.user }}</span>: {{ log.action }}
+                        </div>
+                        {% endfor %}
+                    {% else %}
+                        <p class="text-gray-600 text-center italic mt-10">No recent changes found.</p>
+                    {% endif %}
+                </div>
+            </div>
+            
+            <div class="bg-[#12161f] border border-gray-800 rounded-2xl shadow-xl flex flex-col h-[500px]">
+                <div class="p-6 border-b border-gray-800 bg-gray-900/30"><h3 class="text-lg font-black text-yellow-500 uppercase tracking-tighter italic flex items-center gap-2"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>Time Machine</h3></div>
+                <div class="flex-1 overflow-y-auto custom-scroll p-6 font-mono text-[11px]">
+                    {% if backups %}
+                        {% for b in backups %}
+                        <div class="mb-3 pb-3 border-b border-gray-800 last:border-0 flex justify-between items-center group">
+                            <div>
+                                <span class="text-white block font-bold text-sm">{{ b.time }}</span>
+                                <span class="text-gray-600 text-[9px]">{{ b.file }}</span>
+                            </div>
+                            <form action="/zones/manage/{{ 'root_zone' if domain == '.' else domain }}/rollback/{{ b.ts }}" method="POST" onsubmit="if(!confirm('DANGER! This will overwrite the current zone file with this backup. Continue?')) return false; handleAjaxForm(event, this, 'zone-dynamic-content', restoreRecordTab)">
+                                <button type="submit" class="bg-yellow-600/20 text-yellow-500 hover:bg-yellow-500 hover:text-white transition px-4 py-2 rounded-lg font-black uppercase text-[10px]">Rollback</button>
+                            </form>
+                        </div>
+                        {% endfor %}
+                    {% else %}
+                        <p class="text-gray-600 text-center italic mt-10 text-sm">No backups found. Make a change to create one!</p>
+                    {% endif %}
+                </div>
+            </div>
+        </div>
+    </div> 
+
+    <div id="mainSection_security" class="main-section hidden">
+        <div class="bg-[#12161f] border border-gray-800 rounded-2xl shadow-xl p-8 max-w-4xl mx-auto text-center">
+            <h2 class="text-3xl font-black uppercase tracking-tighter italic mb-4 text-white">DNSSEC Activation</h2>
+            <p class="text-gray-400 text-sm font-bold mb-8">Secure your domain against spoofing with a single click. BIND will handle cryptographic signatures automatically (inline-signing).</p>
+            
+            <div class="flex flex-col items-center justify-center p-8 rounded-xl border-2 {% if dnssec_enabled %}border-green-500/30 bg-green-900/10{% else %}border-gray-800 bg-black/40{% endif %} mb-8">
+                {% if dnssec_enabled %}
+                    <svg class="w-16 h-16 text-green-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
+                    <h3 class="text-2xl font-black text-green-400 uppercase tracking-widest mb-2">Active & Protected</h3>
+                    <p class="text-gray-400 text-xs mb-6">Zone is cryptographically signed via BIND inline-signing.</p>
+                {% else %}
+                    <svg class="w-16 h-16 text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
+                    <h3 class="text-2xl font-black text-gray-500 uppercase tracking-widest mb-2">Not Protected</h3>
+                {% endif %}
+                
+                <form action="/zones/manage/{{ 'root_zone' if domain == '.' else domain }}/dnssec" method="POST" onsubmit="handleAjaxForm(event, this, 'zone-dynamic-content', restoreRecordTab)">
+                    {% if dnssec_enabled %}
+                        <button type="submit" class="bg-red-600/20 text-red-500 hover:bg-red-500 hover:text-white px-8 py-3 rounded-xl font-black uppercase transition text-xs border border-red-500/30">Disable DNSSEC</button>
+                    {% else %}
+                        <button type="submit" class="bg-green-600 text-white hover:bg-green-500 px-8 py-3 rounded-xl font-black uppercase transition text-xs shadow-xl flex items-center gap-2"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg> Enable DNSSEC</button>
+                    {% endif %}
+                </form>
+            </div>
+
+            {% if dnssec_enabled and ds_records %}
+            <div class="text-left bg-black/60 p-6 rounded-xl border border-gray-800">
+                <h4 class="text-sm font-black text-blue-400 uppercase tracking-widest mb-4">DS Records for Registrar</h4>
+                <p class="text-xs text-gray-400 mb-4">Copy and paste these Delegation Signer (DS) records to your Domain Registrar (e.g. GoDaddy, Namecheap) to complete the chain of trust.</p>
+                <div class="space-y-2">
+                    {% for ds in ds_records %}
+                    <input type="text" readonly value="{{ ds }}" class="w-full bg-[#0b0e14] border border-gray-800 rounded-lg p-3 text-green-400 font-mono text-[10px] outline-none cursor-pointer hover:border-green-500/50 transition" onclick="this.select(); document.execCommand('copy'); AppToast.fire({icon:'success', title:'Copied to clipboard!'});">
+                    {% endfor %}
+                </div>
+            </div>
+            {% elif dnssec_enabled %}
+            <p class="text-xs text-yellow-500 font-bold italic">Keys are generating in the background. It may take a minute for DS records to appear here.</p>
+            {% endif %}
+        </div>
+    </div> 
+
+    <div id="editModal" class="hidden fixed inset-0 bg-black/95 items-center justify-center z-50 p-6 transition-opacity">
+        <div class="bg-[#12161f] p-8 rounded-3xl border border-gray-800 shadow-2xl w-full max-w-xl">
+            <h3 class="font-black text-xl mb-8 text-blue-400 uppercase border-b border-gray-800 pb-4">Modify Record</h3>
+            <form action="/zones/manage/{{ 'root_zone' if domain == '.' else domain }}/edit_record" method="POST" class="space-y-6" onsubmit="handleAjaxForm(event, this, 'zone-dynamic-content', () => { restoreRecordTab(); closeEditModal(); })">
+                <input type="hidden" name="old_raw" id="editOldRaw">
+                <div class="grid grid-cols-3 gap-6">
+                    <div class="col-span-2">
+                        <label class="block text-[10px] font-black text-gray-500 uppercase mb-2">Name / IP</label>
+                        <input type="text" name="name" id="editName" required class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white outline-none focus:border-blue-500 transition-colors">
+                    </div>
+                    <div>
+                        <label class="block text-[10px] font-black text-gray-500 uppercase mb-2">Type</label>
+                        <input type="text" name="rtype" id="editRtype" readonly class="w-full bg-gray-900/50 border border-gray-800 rounded-xl p-4 text-gray-500 font-black outline-none cursor-not-allowed">
+                    </div>
+                </div>
+                <div>
+                    <label class="block text-[10px] font-black text-gray-500 uppercase mb-2">Value</label>
+                    <input type="text" name="value" id="editValue" required class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-4 text-white outline-none focus:border-blue-500 transition-colors">
+                    <p id="editHelper" class="text-[9px] text-gray-500 font-black tracking-widest uppercase mt-2 h-3 transition-colors"></p>
+                </div>
+                <div class="flex gap-4 pt-6">
+                    <button type="button" onclick="closeEditModal()" class="flex-1 bg-gray-800 hover:bg-gray-700 transition-colors text-gray-400 font-bold py-4 rounded-xl uppercase text-xs">Cancel</button>
+                    <button type="submit" class="flex-1 bg-blue-600 hover:bg-blue-500 transition-colors text-white font-black py-4 rounded-xl uppercase text-xs flex justify-center items-center">Save Changes</button>
+                </div>
+            </form>
+        </div>
+    </div>
+
+    <div id="editIpsModal" class="hidden fixed inset-0 bg-black/95 backdrop-blur-md items-center justify-center z-50 p-6 transition-opacity">
+        <div class="bg-[#12161f] border border-gray-800 w-full max-w-md rounded-3xl p-8 shadow-2xl">
+            <h3 class="text-xl font-black text-white mb-6 uppercase">Edit DNS Target IPs</h3>
+            <form action="/zones/manage/{{ 'root_zone' if domain == '.' else domain }}/edit_ips" method="POST" class="space-y-4" onsubmit="handleAjaxForm(event, this, 'zone-dynamic-content', restoreRecordTab)">
+                <div id="editIpsContainer" class="space-y-2"></div>
+                <button type="button" onclick="addEditIpField('')" class="w-full py-3 border-2 border-dashed border-gray-800 rounded-xl text-gray-500 text-[11px] font-black uppercase hover:border-gray-600 hover:text-gray-300 transition">+ Add New IP</button>
+                <div class="flex gap-4 pt-6">
+                    <button type="button" onclick="closeEditIpsModal()" class="flex-1 bg-gray-800 text-gray-400 transition-colors font-bold py-3 rounded-xl uppercase text-xs">Cancel</button>
+                    <button type="submit" class="flex-1 bg-blue-600 text-white font-black py-3 rounded-xl uppercase text-xs shadow-xl">Save</button>
+                </div>
+            </form>
+        </div>
+    </div>
+
+    <div id="convertToSlaveModal" class="hidden fixed inset-0 bg-black/95 backdrop-blur-md items-center justify-center z-50 p-6 transition-opacity">
+        <div class="bg-[#12161f] border border-gray-800 w-full max-w-md rounded-3xl p-8 shadow-2xl">
+            <h3 class="text-xl font-black text-purple-400 mb-6 uppercase border-b border-gray-800 pb-4">Convert to Slave</h3>
+            <form action="/zones/manage/{{ 'root_zone' if domain == '.' else domain }}/convert_to_slave" method="POST" class="space-y-4" onsubmit="handleAjaxForm(event, this, 'zone-dynamic-content', restoreRecordTab)">
+                <p class="text-xs text-gray-400 font-bold mb-4">Please provide the Master DNS server IPs for this zone.</p>
+                <div id="slaveIpsContainer" class="space-y-2">
+                    <div class="flex gap-2">
+                        <input type="text" name="master_ips[]" placeholder="Master IP" required class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-3 text-white font-mono text-sm outline-none focus:border-purple-500 transition-colors">
+                    </div>
+                </div>
+                <button type="button" onclick="addSlaveIpField()" class="w-full py-3 border-2 border-dashed border-gray-800 rounded-xl text-gray-500 text-[11px] font-black uppercase transition hover:text-gray-300">+ Add Another IP</button>
+                <div class="flex gap-4 pt-6">
+                    <button type="button" onclick="closeConvertToSlaveModal()" class="flex-1 bg-gray-800 text-gray-400 font-bold py-3 rounded-xl uppercase text-xs">Cancel</button>
+                    <button type="submit" class="flex-1 bg-purple-600 hover:bg-purple-500 text-white font-black py-3 rounded-xl uppercase text-xs shadow-xl transition">Convert</button>
+                </div>
+            </form>
+        </div>
+    </div>
+
+    <div id="digModal" class="hidden fixed inset-0 bg-black/95 backdrop-blur-sm items-center justify-center z-[100] p-6">
+        <div class="bg-[#12161f] border border-gray-800 w-full max-w-2xl rounded-3xl p-8 shadow-2xl flex flex-col max-h-[90vh]">
+            <div class="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
+                <h3 class="text-xl font-black text-green-400 uppercase italic">AXFR Transfer Simulation</h3>
+                <button onclick="closeDigModal()" class="text-gray-500 hover:text-white text-2xl font-bold">&times;</button>
+            </div>
+            
+            <div id="digLoading" class="py-20 text-center">
+                <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500 mx-auto mb-4"></div>
+                <p class="text-gray-500 font-black uppercase text-xs tracking-widest">Running Dig AXFR...</p>
+            </div>
+
+            <div id="digContent" class="hidden overflow-y-auto">
+                <div class="grid grid-cols-2 gap-4 mb-6" id="digAnalysis">
+                    </div>
+                
+                <h4 class="text-[10px] font-black text-gray-500 uppercase mb-2 tracking-widest">Raw Output</h4>
+                <pre id="digRaw" class="bg-black p-4 rounded-xl text-[10px] text-green-600 font-mono overflow-x-auto border border-gray-800 custom-scroll"></pre>
+            </div>
+
+            <div class="mt-8">
+                <button onclick="closeDigModal()" class="w-full bg-gray-800 hover:bg-gray-700 py-4 rounded-xl text-white font-black uppercase text-xs transition">Close</button>
+            </div>
+        </div>
+    </div>
+
+</div>
+
+<script>
+let activeRecordTab = '';
+let activeMainTab = 'records';
+
+const recordRules = {
+    'A': { regex: /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/, msg: "Must be a valid IPv4 address", placeholder: "192.168.1.1" },
+    'AAAA': { regex: /^[a-fA-F0-9:]+$/, msg: "Must be a valid IPv6 address", placeholder: "2001:db8::1" },
+    'CNAME': { regex: /^[a-zA-Z0-9\-\_\.]+$/, msg: "Must be a domain without spaces", placeholder: "target.example.com." },
+    'NS': { regex: /^[a-zA-Z0-9\-\_\.]+$/, msg: "Must be a valid Nameserver domain", placeholder: "ns1.example.com." },
+    'TXT': { regex: /^.*$/, msg: "Any text value (Usually wrapped in quotes)", placeholder: '"text here"' },
+    'MX': { regex: /^\d+\s+[a-zA-Z0-9\-\_\.]+$/, msg: "Format: Priority Domain", placeholder: "10 mail.example.com." },
+    'SRV': { regex: /^\d+\s+\d+\s+\d+\s+[a-zA-Z0-9\-\_\.]+$/, msg: "Format: Priority Weight Port Domain", placeholder: "10 5 5060 sip.com." },
+    'PTR': { regex: /^[a-zA-Z0-9\-\_\.]+$/, msg: "Must be a valid domain name", placeholder: "host.example.com." }
+};
+
+// --- SPA & AJAX SMART PARSER ---
+async function handleAjaxForm(e, form, containerId, callback) {
+    e.preventDefault();
+    const btn = form.querySelector('button[type="submit"]');
+    if(btn) { btn.innerHTML = spinnerSvg + ' WAIT'; btn.disabled = true; }
+    
+    try {
+        const res = await fetch(form.action, { method: form.method || 'POST', body: new FormData(form) });
+        const html = await res.text();
+        const doc = new DOMParser().parseFromString(html, 'text/html');
+        
+        const scriptContent = Array.from(doc.querySelectorAll('script')).map(s => s.innerHTML).join('\n');
+        const errorMatch = scriptContent.match(/Toast\.fire\(\{\s*icon:\s*'error',\s*title:\s*'([^']+)'/);
+        const successMatch = scriptContent.match(/Toast\.fire\(\{\s*icon:\s*'success',\s*title:\s*'([^']+)'/);
+
+        const newCont = doc.getElementById(containerId);
+        if(newCont) {
+            document.getElementById(containerId).innerHTML = newCont.innerHTML;
+            if(callback) callback();
+            
+            if(errorMatch) {
+                AppToast.fire({ icon: 'error', title: errorMatch[1] });
+            } else if(successMatch) {
+                AppToast.fire({ icon: 'success', title: successMatch[1] });
+            } else {
+                AppToast.fire({ icon: 'success', title: 'Action Completed!' });
+            }
+        } else window.location.reload();
+    } catch(err) { window.location.reload(); }
+}
+
+async function handleAjaxLink(e, link, containerId, callback, confirmMsg) {
+    e.preventDefault();
+    if(confirmMsg && !confirm(confirmMsg)) return;
+    try {
+        const res = await fetch(link.href);
+        const html = await res.text();
+        const doc = new DOMParser().parseFromString(html, 'text/html');
+        
+        const scriptContent = Array.from(doc.querySelectorAll('script')).map(s => s.innerHTML).join('\n');
+        const errorMatch = scriptContent.match(/Toast\.fire\(\{\s*icon:\s*'error',\s*title:\s*'([^']+)'/);
+        const successMatch = scriptContent.match(/Toast\.fire\(\{\s*icon:\s*'success',\s*title:\s*'([^']+)'/);
+
+        const newCont = doc.getElementById(containerId);
+        if(newCont) {
+            document.getElementById(containerId).innerHTML = newCont.innerHTML;
+            if(callback) callback();
+            
+            if(errorMatch) {
+                AppToast.fire({ icon: 'error', title: errorMatch[1] });
+            } else if(successMatch) {
+                AppToast.fire({ icon: 'success', title: successMatch[1] });
+            } else {
+                AppToast.fire({ icon: 'success', title: 'Action Executed!' });
+            }
+        } else window.location.reload();
+    } catch(err) { window.location.reload(); }
+}
+
+function initTableSort(container) {
+    const getCellValue = (tr, idx) => tr.children[idx].innerText.trim();
+    const comparer = (idx, asc) => (a, b) => ((v1, v2) => 
+        v1 !== '' && v2 !== '' && !isNaN(v1) && !isNaN(v2) ? v1 - v2 : v1.localeCompare(v2, undefined, {numeric: true})
+    )(getCellValue(asc ? a : b, idx), getCellValue(asc ? b : a, idx));
+
+    container.querySelectorAll('table').forEach(table => {
+        if(table.dataset.sortBound) return;
+        table.dataset.sortBound = "true";
+        table.querySelectorAll('th').forEach(th => {
+            if (th.innerText.trim().toLowerCase() === 'actions') return;
+            th.classList.add('cursor-pointer', 'hover:text-blue-400', 'transition-colors', 'select-none', 'group');
+            const originalText = th.innerHTML;
+            if(!originalText.includes('sort-icon')) {
+                th.innerHTML = `<div class="flex items-center gap-2 justify-${th.classList.contains('text-right') ? 'end' : 'start'}">${originalText}<span class="sort-icon opacity-0 group-hover:opacity-50 transition-opacity text-blue-500 text-lg leading-none">↕</span></div>`;
+            }
+            th.addEventListener('click', function() {
+                const tbody = table.querySelector('tbody');
+                if(!tbody) return;
+                table.querySelectorAll('.sort-icon').forEach(icon => {
+                    icon.innerHTML = '↕';
+                    icon.classList.remove('opacity-100', 'text-blue-400');
+                    icon.classList.add('opacity-0', 'text-blue-500');
+                });
+                this.asc = !this.asc;
+                const icon = this.querySelector('.sort-icon');
+                icon.innerHTML = this.asc ? '↑' : '↓';
+                icon.classList.remove('opacity-0', 'text-blue-500', 'group-hover:opacity-50');
+                icon.classList.add('opacity-100', 'text-blue-400');
+                Array.from(tbody.querySelectorAll('tr'))
+                    .sort(comparer(Array.from(th.parentNode.children).indexOf(th), this.asc))
+                    .forEach(tr => tbody.appendChild(tr));
+            });
+        });
+    });
+}
+
+function updateValidation(prefix) {
+    const typeEl = document.getElementById(prefix + 'Rtype');
+    const inputEl = document.getElementById(prefix + 'Value');
+    const helperEl = document.getElementById(prefix + 'Helper');
+    if(!typeEl || !inputEl || !helperEl) return;
+    const rule = recordRules[typeEl.value];
+    inputEl.placeholder = rule.placeholder;
+    helperEl.innerText = rule.msg;
+    inputEl.setCustomValidity('');
+    inputEl.oninput = function() {
+        let isValid = rule.regex.test(inputEl.value.trim());
+        inputEl.setCustomValidity(isValid ? '' : rule.msg);
+        helperEl.className = "text-[9px] font-black tracking-widest uppercase mt-2 h-3 " + (isValid ? "text-gray-500" : "text-red-500");
+    };
+    if(inputEl.value) inputEl.oninput();
+}
+
+function switchMainTab(tab) {
+    activeMainTab = tab;
+    document.querySelectorAll('.main-section').forEach(el => el.classList.add('hidden'));
+    document.querySelectorAll('[id^="mainTab_"]').forEach(btn => {
+        btn.classList.remove('border-blue-500', 'text-blue-500');
+        btn.classList.add('border-transparent', 'text-gray-500');
+    });
+    
+    document.getElementById('mainSection_' + tab).classList.remove('hidden');
+    const btn = document.getElementById('mainTab_' + tab);
+    if(btn) {
+        btn.classList.replace('border-transparent', 'border-blue-500');
+        btn.classList.replace('text-gray-500', 'text-blue-500');
+    }
+}
+
+function switchTab(selectedType) {
+    activeRecordTab = selectedType;
+    document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
+    document.querySelectorAll('.tab-btn').forEach(btn => {
+        btn.classList.remove('border-blue-500', 'text-blue-400');
+        btn.classList.add('border-transparent', 'text-gray-500');
+    });
+    const tabEl = document.getElementById('tab-' + selectedType);
+    if(tabEl) tabEl.classList.remove('hidden');
+    const activeBtn = document.getElementById('btn-' + selectedType);
+    if(activeBtn) {
+        activeBtn.classList.replace('border-transparent', 'border-blue-500');
+        activeBtn.classList.replace('text-gray-500', 'text-blue-400');
+    }
+}
+
+function restoreRecordTab() {
+    switchMainTab(activeMainTab);
+    if(document.getElementById('addRtype')) updateValidation('add');
+    if(activeRecordTab && document.getElementById('btn-' + activeRecordTab)) {
+        switchTab(activeRecordTab);
+    } else {
+        const firstBtn = document.querySelector('.tab-btn');
+        if(firstBtn) switchTab(firstBtn.id.replace('btn-', ''));
+    }
+    initTableSort(document.getElementById('zone-dynamic-content'));
+}
+
+function openEditModal(raw, name, type, val) {
+    document.getElementById('editOldRaw').value = raw;
+    document.getElementById('editName').value = name;
+    document.getElementById('editRtype').value = type;
+    document.getElementById('editValue').value = val;
+    document.getElementById('editModal').classList.replace('hidden', 'flex');
+    updateValidation('edit');
+    setTimeout(() => {
+        const input = document.getElementById('editValue');
+        input.focus();
+        input.setSelectionRange(input.value.length, input.value.length);
+    }, 50);
+}
+function closeEditModal() { document.getElementById('editModal').classList.replace('flex', 'hidden'); }
+
+function openEditIpsModal(currentIps) {
+    const cont = document.getElementById('editIpsContainer');
+    cont.innerHTML = '';
+    currentIps.split(' ').forEach(ip => { if(ip.trim()) addEditIpField(ip.trim()); });
+    document.getElementById('editIpsModal').classList.replace('hidden', 'flex');
+}
+function closeEditIpsModal() { document.getElementById('editIpsModal').classList.replace('flex', 'hidden'); }
+function addEditIpField(val) {
+    const div = document.createElement('div'); div.className = 'flex gap-2';
+    div.innerHTML = `<input type="text" name="ips[]" value="${val}" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-3 text-white font-mono text-sm outline-none focus:border-blue-500 transition-colors"><button type="button" onclick="this.parentElement.remove()" class="text-gray-600 hover:text-red-500 px-3 font-black text-xl transition-colors">×</button>`;
+    document.getElementById('editIpsContainer').appendChild(div);
+}
+
+function openConvertToSlaveModal() { document.getElementById('convertToSlaveModal').classList.replace('hidden', 'flex'); }
+function closeConvertToSlaveModal() { document.getElementById('convertToSlaveModal').classList.replace('flex', 'hidden'); }
+function addSlaveIpField() {
+    const div = document.createElement('div'); div.className = 'flex gap-2 mt-2';
+    div.innerHTML = `<input type="text" name="master_ips[]" placeholder="Master IP" required class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-3 text-white font-mono text-sm outline-none focus:border-purple-500 transition-colors"><button type="button" onclick="this.parentElement.remove()" class="text-gray-600 hover:text-red-500 px-3 font-black text-xl transition-colors">×</button>`;
+    document.getElementById('slaveIpsContainer').appendChild(div);
+}
+
+function runDigTest(domain) {
+    const modal = document.getElementById('digModal');
+    const loading = document.getElementById('digLoading');
+    const content = document.getElementById('digContent');
+    const analysisCont = document.getElementById('digAnalysis');
+    const rawCont = document.getElementById('digRaw');
+
+    modal.classList.replace('hidden', 'flex');
+    loading.classList.remove('hidden');
+    content.classList.add('hidden');
+
+    fetch('/api/zones/test_dig', {
+        method: 'POST',
+        headers: {'Content-Type': 'application/json'},
+        body: JSON.stringify({domain: domain, target_ip: '127.0.0.1'})
+    })
+    .then(r => r.json())
+    .then(data => {
+        loading.classList.add('hidden');
+        content.classList.remove('hidden');
+        
+        if(data.success) {
+            analysisCont.innerHTML = `
+                <div class="bg-green-900/10 border border-green-500/20 p-4 rounded-2xl">
+                    <p class="text-[9px] text-gray-500 uppercase font-black">Total Records</p>
+                    <p class="text-2xl font-black text-white">${data.total}</p>
+                </div>
+                <div class="bg-blue-900/10 border border-blue-500/20 p-4 rounded-2xl flex flex-wrap gap-2 items-center">
+                    ${Object.entries(data.analysis).map(([type, count]) => `
+                        <div class="flex items-center gap-1 bg-black/40 px-2 py-1 rounded border border-gray-800">
+                            <span class="text-blue-400 font-bold text-[10px]">${type}:</span>
+                            <span class="text-white font-black text-[10px]">${count}</span>
+                        </div>
+                    `).join('')}
+                </div>
+            `;
+        } else {
+            analysisCont.innerHTML = `<div class="col-span-2 bg-red-900/20 border border-red-500/30 p-4 rounded-xl text-red-500 font-bold text-xs">Error: ${data.message}</div>`;
+        }
+        rawCont.innerText = data.raw;
+    })
+    .catch(err => {
+        loading.classList.add('hidden');
+        content.classList.remove('hidden');
+        analysisCont.innerHTML = `<div class="col-span-2 bg-red-900/20 border border-red-500/30 p-4 rounded-xl text-red-500 font-bold text-xs">API Connection Failed</div>`;
+    });
+}
+
+function closeDigModal() { document.getElementById('digModal').classList.replace('flex', 'hidden'); }
+
+document.addEventListener("DOMContentLoaded", () => { restoreRecordTab(); });
+</script>
+{% endblock %}

+ 99 - 0
templates/users.html

@@ -0,0 +1,99 @@
+{% extends "layout.html" %}
+{% block content %}
+<div class="flex justify-between items-center mb-10">
+    <div>
+        <h2 class="text-4xl font-black text-white tracking-tighter italic">User Management</h2>
+        <p class="text-gray-500 font-bold uppercase text-[10px] tracking-widest mt-2">Manage access and RBAC roles</p>
+    </div>
+    <button onclick="openAddUser()" class="bg-blue-600 hover:bg-blue-500 px-8 py-3 rounded-xl font-black shadow-2xl transition transform hover:scale-105 text-white uppercase text-xs tracking-widest">+ Create User</button>
+</div>
+
+<div class="bg-[#12161f] border border-gray-800 rounded-3xl overflow-hidden shadow-2xl">
+    <table class="w-full text-left">
+        <thead class="bg-black/20 text-[10px] font-black text-gray-500 uppercase tracking-widest border-b border-gray-800">
+            <tr>
+                <th class="p-6">Username</th>
+                <th class="p-6">Role</th>
+                <th class="p-6">Templates</th>
+                <th class="p-6 text-right">Actions</th>
+            </tr>
+        </thead>
+        <tbody class="divide-y divide-gray-800">
+            {% for u, data in users.items() %}
+            <tr class="hover:bg-white/[0.02] transition">
+                <td class="p-6 font-bold text-white">{{ u }}</td>
+                <td class="p-6">
+                    <span class="px-3 py-1 rounded-full text-[9px] font-black uppercase border {% if data.role=='admin' %}border-red-500/30 text-red-400 bg-red-500/10{% else %}border-blue-500/30 text-blue-400 bg-blue-500/10{% endif %}">
+                        {{ data.role }}
+                    </span>
+                </td>
+                <td class="p-6 text-xs text-gray-500">{{ data.assigned_templates|join(', ') or 'None' }}</td>
+                <td class="p-6 text-right">
+                    <button onclick="editUser('{{u}}', '{{data.role}}', {{data.assigned_templates|tojson}})" class="text-blue-500 hover:text-blue-400 text-[10px] font-black uppercase mr-4">Edit</button>
+                    {% if u != 'admin' %}
+                    <a href="/users/delete/{{ u }}" onclick="return confirm('Delete user?')" class="text-red-500 hover:text-red-400 text-[10px] font-black uppercase">Delete</a>
+                    {% endif %}
+                </td>
+            </tr>
+            {% endfor %}
+        </tbody>
+    </table>
+</div>
+
+<div id="userModal" class="hidden fixed inset-0 bg-black/90 backdrop-blur-md flex items-center justify-center z-50 p-4">
+    <div class="bg-[#12161f] border border-gray-800 w-full max-w-md rounded-3xl p-8 shadow-2xl">
+        <h3 id="modalTitle" class="text-xl font-black text-white mb-6 uppercase">Add User</h3>
+        <form action="/users/save" method="POST" class="space-y-4">
+            <input type="hidden" name="action" id="userAction" value="add">
+            <input type="hidden" name="old_username" id="oldUsername">
+            
+            <input type="text" name="username" id="userName" placeholder="Username" required class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-3 text-sm font-bold text-white outline-none">
+            <input type="password" name="password" id="userPass" placeholder="Password" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-3 text-sm font-bold text-white outline-none">
+            
+            <select name="role" id="userRole" class="w-full bg-[#0b0e14] border border-gray-800 rounded-xl p-3 text-sm font-bold text-white outline-none">
+                <option value="user">User (Restricted)</option>
+                <option value="admin">Administrator</option>
+            </select>
+
+            <div class="bg-black/20 p-4 rounded-xl border border-gray-800">
+                <label class="text-[10px] font-black text-gray-500 uppercase block mb-3">Assigned Templates</label>
+                <div class="grid grid-cols-2 gap-2">
+                    {% for t in all_templates %}
+                    <label class="flex items-center gap-2 text-xs text-gray-400 cursor-pointer hover:text-white">
+                        <input type="checkbox" name="assigned_templates[]" value="{{ t }}" class="template-check rounded border-gray-700 bg-gray-900 text-blue-600">
+                        {{ t }}
+                    </label>
+                    {% endfor %}
+                </div>
+            </div>
+
+            <div class="flex gap-4 pt-4">
+                <button type="button" onclick="closeUserModal()" class="flex-1 bg-gray-800 text-gray-400 font-bold py-3 rounded-xl uppercase text-xs">Cancel</button>
+                <button type="submit" class="flex-1 bg-blue-600 text-white font-black py-3 rounded-xl uppercase text-xs">Save</button>
+            </div>
+        </form>
+    </div>
+</div>
+
+<script>
+function openAddUser() {
+    document.getElementById('userModal').classList.remove('hidden');
+    document.getElementById('userAction').value = 'add';
+    document.getElementById('modalTitle').innerText = 'Add User';
+    document.getElementById('userName').value = '';
+    document.querySelectorAll('.template-check').forEach(c => c.checked = false);
+}
+function editUser(u, role, templates) {
+    document.getElementById('userModal').classList.remove('hidden');
+    document.getElementById('userAction').value = 'edit';
+    document.getElementById('oldUsername').value = u;
+    document.getElementById('userName').value = u;
+    document.getElementById('userRole').value = role;
+    document.getElementById('modalTitle').innerText = 'Edit User';
+    document.querySelectorAll('.template-check').forEach(c => {
+        c.checked = templates.includes(c.value);
+    });
+}
+function closeUserModal() { document.getElementById('userModal').classList.add('hidden'); }
+</script>
+{% endblock %}

+ 57 - 0
templates/zones.html

@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html lang="el">
+<head>
+    <meta charset="UTF-8">
+    <title>Zones - DNS Manager</title>
+    <script src="https://cdn.tailwindcss.com"></script>
+</head>
+<body class="bg-gray-900 text-white font-sans">
+    <div class="flex h-screen">
+        <aside class="w-64 bg-gray-800 border-r border-gray-700 p-6">
+            <h2 class="text-2xl font-bold text-blue-400 mb-8">DNS Manager</h2>
+            <nav class="space-y-2">
+                <a href="/" class="block px-4 py-2 text-gray-400 hover:bg-gray-700 rounded">Dashboard</a>
+                <a href="/zones" class="block px-4 py-2 bg-blue-600 rounded">Zone Management</a>
+            </nav>
+        </aside>
+
+        <main class="flex-1 p-8">
+            <div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
+                <div class="bg-gray-800 p-6 rounded-xl border border-gray-700 shadow-xl">
+                    <h2 class="text-xl font-bold mb-4">New Zone</h2>
+                    <form action="/zones/add" method="POST" class="space-y-4">
+                        <input type="text" name="domain" placeholder="example.com" required class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2">
+                        <select name="type" id="ztype" onchange="document.getElementById('m_ip').style.display = (this.value=='master'?'block':'none'); document.getElementById('s_ip').style.display = (this.value=='slave'?'block':'none');" class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2">
+                            <option value="master">Master</option>
+                            <option value="slave">Slave</option>
+                        </select>
+                        <div id="m_ip"><input type="text" name="ip" placeholder="Server IP" class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2"></div>
+                        <div id="s_ip" style="display:none"><input type="text" name="master_ip" placeholder="Master DNS IP" class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-2"></div>
+                        <button class="w-full bg-blue-600 hover:bg-blue-500 py-2 rounded font-bold">Create</button>
+                    </form>
+                </div>
+
+                <div class="lg:col-span-2 bg-gray-800 rounded-xl border border-gray-700 overflow-hidden shadow-xl">
+                    <table class="w-full text-left">
+                        <thead class="bg-gray-700 text-gray-400 uppercase text-xs">
+                            <tr><th class="px-6 py-4">Domain</th><th class="px-6 py-4">Type</th><th class="px-6 py-4 text-right">Actions</th></tr>
+                        </thead>
+                        <tbody class="divide-y divide-gray-700">
+                            {% for domain, type in zones %}
+                            <tr class="hover:bg-gray-750 transition">
+                                <td class="px-6 py-4 font-bold">{{ domain }}</td>
+                                <td class="px-6 py-4"><span class="px-2 py-1 rounded text-xs {% if type=='master' %}bg-blue-900 text-blue-200{% else %}bg-purple-900 text-purple-200{% endif %}">{{ type }}</span></td>
+                                <td class="px-6 py-4 text-right">
+                                    <a href="/zones/manage/{{ domain }}" class="text-blue-400 hover:underline mr-4">Manage</a>
+                                    <a href="/zones/delete/{{ domain }}" class="text-red-500 hover:underline">Delete</a>
+                                </td>
+                            </tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                </div>
+            </div>
+        </main>
+    </div>
+</body>
+</html>

+ 241 - 0
venv/bin/Activate.ps1

@@ -0,0 +1,241 @@
+<#
+.Synopsis
+Activate a Python virtual environment for the current PowerShell session.
+
+.Description
+Pushes the python executable for a virtual environment to the front of the
+$Env:PATH environment variable and sets the prompt to signify that you are
+in a Python virtual environment. Makes use of the command line switches as
+well as the `pyvenv.cfg` file values present in the virtual environment.
+
+.Parameter VenvDir
+Path to the directory that contains the virtual environment to activate. The
+default value for this is the parent of the directory that the Activate.ps1
+script is located within.
+
+.Parameter Prompt
+The prompt prefix to display when this virtual environment is activated. By
+default, this prompt is the name of the virtual environment folder (VenvDir)
+surrounded by parentheses and followed by a single space (ie. '(.venv) ').
+
+.Example
+Activate.ps1
+Activates the Python virtual environment that contains the Activate.ps1 script.
+
+.Example
+Activate.ps1 -Verbose
+Activates the Python virtual environment that contains the Activate.ps1 script,
+and shows extra information about the activation as it executes.
+
+.Example
+Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
+Activates the Python virtual environment located in the specified location.
+
+.Example
+Activate.ps1 -Prompt "MyPython"
+Activates the Python virtual environment that contains the Activate.ps1 script,
+and prefixes the current prompt with the specified string (surrounded in
+parentheses) while the virtual environment is active.
+
+.Notes
+On Windows, it may be required to enable this Activate.ps1 script by setting the
+execution policy for the user. You can do this by issuing the following PowerShell
+command:
+
+PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
+
+For more information on Execution Policies: 
+https://go.microsoft.com/fwlink/?LinkID=135170
+
+#>
+Param(
+    [Parameter(Mandatory = $false)]
+    [String]
+    $VenvDir,
+    [Parameter(Mandatory = $false)]
+    [String]
+    $Prompt
+)
+
+<# Function declarations --------------------------------------------------- #>
+
+<#
+.Synopsis
+Remove all shell session elements added by the Activate script, including the
+addition of the virtual environment's Python executable from the beginning of
+the PATH variable.
+
+.Parameter NonDestructive
+If present, do not remove this function from the global namespace for the
+session.
+
+#>
+function global:deactivate ([switch]$NonDestructive) {
+    # Revert to original values
+
+    # The prior prompt:
+    if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
+        Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
+        Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
+    }
+
+    # The prior PYTHONHOME:
+    if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
+        Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
+        Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
+    }
+
+    # The prior PATH:
+    if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
+        Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
+        Remove-Item -Path Env:_OLD_VIRTUAL_PATH
+    }
+
+    # Just remove the VIRTUAL_ENV altogether:
+    if (Test-Path -Path Env:VIRTUAL_ENV) {
+        Remove-Item -Path env:VIRTUAL_ENV
+    }
+
+    # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
+    if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
+        Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
+    }
+
+    # Leave deactivate function in the global namespace if requested:
+    if (-not $NonDestructive) {
+        Remove-Item -Path function:deactivate
+    }
+}
+
+<#
+.Description
+Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
+given folder, and returns them in a map.
+
+For each line in the pyvenv.cfg file, if that line can be parsed into exactly
+two strings separated by `=` (with any amount of whitespace surrounding the =)
+then it is considered a `key = value` line. The left hand string is the key,
+the right hand is the value.
+
+If the value starts with a `'` or a `"` then the first and last character is
+stripped from the value before being captured.
+
+.Parameter ConfigDir
+Path to the directory that contains the `pyvenv.cfg` file.
+#>
+function Get-PyVenvConfig(
+    [String]
+    $ConfigDir
+) {
+    Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
+
+    # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
+    $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
+
+    # An empty map will be returned if no config file is found.
+    $pyvenvConfig = @{ }
+
+    if ($pyvenvConfigPath) {
+
+        Write-Verbose "File exists, parse `key = value` lines"
+        $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
+
+        $pyvenvConfigContent | ForEach-Object {
+            $keyval = $PSItem -split "\s*=\s*", 2
+            if ($keyval[0] -and $keyval[1]) {
+                $val = $keyval[1]
+
+                # Remove extraneous quotations around a string value.
+                if ("'""".Contains($val.Substring(0, 1))) {
+                    $val = $val.Substring(1, $val.Length - 2)
+                }
+
+                $pyvenvConfig[$keyval[0]] = $val
+                Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
+            }
+        }
+    }
+    return $pyvenvConfig
+}
+
+
+<# Begin Activate script --------------------------------------------------- #>
+
+# Determine the containing directory of this script
+$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$VenvExecDir = Get-Item -Path $VenvExecPath
+
+Write-Verbose "Activation script is located in path: '$VenvExecPath'"
+Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
+Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
+
+# Set values required in priority: CmdLine, ConfigFile, Default
+# First, get the location of the virtual environment, it might not be
+# VenvExecDir if specified on the command line.
+if ($VenvDir) {
+    Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
+}
+else {
+    Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
+    $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
+    Write-Verbose "VenvDir=$VenvDir"
+}
+
+# Next, read the `pyvenv.cfg` file to determine any required value such
+# as `prompt`.
+$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
+
+# Next, set the prompt from the command line, or the config file, or
+# just use the name of the virtual environment folder.
+if ($Prompt) {
+    Write-Verbose "Prompt specified as argument, using '$Prompt'"
+}
+else {
+    Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
+    if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
+        Write-Verbose "  Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
+        $Prompt = $pyvenvCfg['prompt'];
+    }
+    else {
+        Write-Verbose "  Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virutal environment)"
+        Write-Verbose "  Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
+        $Prompt = Split-Path -Path $venvDir -Leaf
+    }
+}
+
+Write-Verbose "Prompt = '$Prompt'"
+Write-Verbose "VenvDir='$VenvDir'"
+
+# Deactivate any currently active virtual environment, but leave the
+# deactivate function in place.
+deactivate -nondestructive
+
+# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
+# that there is an activated venv.
+$env:VIRTUAL_ENV = $VenvDir
+
+if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
+
+    Write-Verbose "Setting prompt to '$Prompt'"
+
+    # Set the prompt to include the env name
+    # Make sure _OLD_VIRTUAL_PROMPT is global
+    function global:_OLD_VIRTUAL_PROMPT { "" }
+    Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
+    New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
+
+    function global:prompt {
+        Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
+        _OLD_VIRTUAL_PROMPT
+    }
+}
+
+# Clear PYTHONHOME
+if (Test-Path -Path Env:PYTHONHOME) {
+    Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
+    Remove-Item -Path Env:PYTHONHOME
+}
+
+# Add the venv to the PATH
+Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
+$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"

+ 66 - 0
venv/bin/activate

@@ -0,0 +1,66 @@
+# This file must be used with "source bin/activate" *from bash*
+# you cannot run it directly
+
+deactivate () {
+    # reset old environment variables
+    if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
+        PATH="${_OLD_VIRTUAL_PATH:-}"
+        export PATH
+        unset _OLD_VIRTUAL_PATH
+    fi
+    if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
+        PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
+        export PYTHONHOME
+        unset _OLD_VIRTUAL_PYTHONHOME
+    fi
+
+    # This should detect bash and zsh, which have a hash command that must
+    # be called to get it to forget past commands.  Without forgetting
+    # past commands the $PATH changes we made may not be respected
+    if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
+        hash -r 2> /dev/null
+    fi
+
+    if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
+        PS1="${_OLD_VIRTUAL_PS1:-}"
+        export PS1
+        unset _OLD_VIRTUAL_PS1
+    fi
+
+    unset VIRTUAL_ENV
+    if [ ! "${1:-}" = "nondestructive" ] ; then
+    # Self destruct!
+        unset -f deactivate
+    fi
+}
+
+# unset irrelevant variables
+deactivate nondestructive
+
+VIRTUAL_ENV=/opt/bindmanager/venv
+export VIRTUAL_ENV
+
+_OLD_VIRTUAL_PATH="$PATH"
+PATH="$VIRTUAL_ENV/"bin":$PATH"
+export PATH
+
+# unset PYTHONHOME if set
+# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
+# could use `if (set -u; : $PYTHONHOME) ;` in bash
+if [ -n "${PYTHONHOME:-}" ] ; then
+    _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
+    unset PYTHONHOME
+fi
+
+if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
+    _OLD_VIRTUAL_PS1="${PS1:-}"
+    PS1='(venv) '"${PS1:-}"
+    export PS1
+fi
+
+# This should detect bash and zsh, which have a hash command that must
+# be called to get it to forget past commands.  Without forgetting
+# past commands the $PATH changes we made may not be respected
+if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
+    hash -r 2> /dev/null
+fi

+ 25 - 0
venv/bin/activate.csh

@@ -0,0 +1,25 @@
+# This file must be used with "source bin/activate.csh" *from csh*.
+# You cannot run it directly.
+# Created by Davide Di Blasi <davidedb@gmail.com>.
+# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
+
+alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; test "\!:*" != "nondestructive" && unalias deactivate'
+
+# Unset irrelevant variables.
+deactivate nondestructive
+
+setenv VIRTUAL_ENV /opt/bindmanager/venv
+
+set _OLD_VIRTUAL_PATH="$PATH"
+setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
+
+
+set _OLD_VIRTUAL_PROMPT="$prompt"
+
+if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
+    set prompt = '(venv) '"$prompt"
+endif
+
+alias pydoc python -m pydoc
+
+rehash

+ 64 - 0
venv/bin/activate.fish

@@ -0,0 +1,64 @@
+# This file must be used with "source <venv>/bin/activate.fish" *from fish*
+# (https://fishshell.com/); you cannot run it directly.
+
+function deactivate  -d "Exit virtual environment and return to normal shell environment"
+    # reset old environment variables
+    if test -n "$_OLD_VIRTUAL_PATH"
+        set -gx PATH $_OLD_VIRTUAL_PATH
+        set -e _OLD_VIRTUAL_PATH
+    end
+    if test -n "$_OLD_VIRTUAL_PYTHONHOME"
+        set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
+        set -e _OLD_VIRTUAL_PYTHONHOME
+    end
+
+    if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
+        functions -e fish_prompt
+        set -e _OLD_FISH_PROMPT_OVERRIDE
+        functions -c _old_fish_prompt fish_prompt
+        functions -e _old_fish_prompt
+    end
+
+    set -e VIRTUAL_ENV
+    if test "$argv[1]" != "nondestructive"
+        # Self-destruct!
+        functions -e deactivate
+    end
+end
+
+# Unset irrelevant variables.
+deactivate nondestructive
+
+set -gx VIRTUAL_ENV /opt/bindmanager/venv
+
+set -gx _OLD_VIRTUAL_PATH $PATH
+set -gx PATH "$VIRTUAL_ENV/"bin $PATH
+
+# Unset PYTHONHOME if set.
+if set -q PYTHONHOME
+    set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
+    set -e PYTHONHOME
+end
+
+if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
+    # fish uses a function instead of an env var to generate the prompt.
+
+    # Save the current fish_prompt function as the function _old_fish_prompt.
+    functions -c fish_prompt _old_fish_prompt
+
+    # With the original prompt function renamed, we can override with our own.
+    function fish_prompt
+        # Save the return status of the last command.
+        set -l old_status $status
+
+        # Output the venv prompt; color taken from the blue of the Python logo.
+        printf "%s%s%s" (set_color 4B8BBE) '(venv) ' (set_color normal)
+
+        # Restore the return status of the previous command.
+        echo "exit $old_status" | .
+        # Output the original/"old" prompt.
+        _old_fish_prompt
+    end
+
+    set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
+end

+ 8 - 0
venv/bin/easy_install

@@ -0,0 +1,8 @@
+#!/opt/bindmanager/venv/bin/python3
+# -*- coding: utf-8 -*-
+import re
+import sys
+from setuptools.command.easy_install import main
+if __name__ == '__main__':
+    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
+    sys.exit(main())

+ 8 - 0
venv/bin/easy_install-3.9

@@ -0,0 +1,8 @@
+#!/opt/bindmanager/venv/bin/python3
+# -*- coding: utf-8 -*-
+import re
+import sys
+from setuptools.command.easy_install import main
+if __name__ == '__main__':
+    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
+    sys.exit(main())

+ 6 - 0
venv/bin/flask

@@ -0,0 +1,6 @@
+#!/opt/bindmanager/venv/bin/python3
+import sys
+from flask.cli import main
+if __name__ == '__main__':
+    sys.argv[0] = sys.argv[0].removesuffix('.exe')
+    sys.exit(main())

+ 8 - 0
venv/bin/pip

@@ -0,0 +1,8 @@
+#!/opt/bindmanager/venv/bin/python3
+# -*- coding: utf-8 -*-
+import re
+import sys
+from pip._internal.cli.main import main
+if __name__ == '__main__':
+    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
+    sys.exit(main())

+ 8 - 0
venv/bin/pip3

@@ -0,0 +1,8 @@
+#!/opt/bindmanager/venv/bin/python3
+# -*- coding: utf-8 -*-
+import re
+import sys
+from pip._internal.cli.main import main
+if __name__ == '__main__':
+    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
+    sys.exit(main())

+ 8 - 0
venv/bin/pip3.9

@@ -0,0 +1,8 @@
+#!/opt/bindmanager/venv/bin/python3
+# -*- coding: utf-8 -*-
+import re
+import sys
+from pip._internal.cli.main import main
+if __name__ == '__main__':
+    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
+    sys.exit(main())

+ 1 - 0
venv/bin/python

@@ -0,0 +1 @@
+python3

+ 1 - 0
venv/bin/python3

@@ -0,0 +1 @@
+/usr/bin/python3

+ 1 - 0
venv/bin/python3.9

@@ -0,0 +1 @@
+python3

二进制
venv/lib/python3.9/site-packages/__pycache__/easy_install.cpython-39.pyc


+ 1 - 0
venv/lib/python3.9/site-packages/blinker-1.9.0.dist-info/INSTALLER

@@ -0,0 +1 @@
+pip

+ 20 - 0
venv/lib/python3.9/site-packages/blinker-1.9.0.dist-info/LICENSE.txt

@@ -0,0 +1,20 @@
+Copyright 2010 Jason Kirtland
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 60 - 0
venv/lib/python3.9/site-packages/blinker-1.9.0.dist-info/METADATA

@@ -0,0 +1,60 @@
+Metadata-Version: 2.3
+Name: blinker
+Version: 1.9.0
+Summary: Fast, simple object-to-object and broadcast signaling
+Author: Jason Kirtland
+Maintainer-email: Pallets Ecosystem <contact@palletsprojects.com>
+Requires-Python: >=3.9
+Description-Content-Type: text/markdown
+Classifier: Development Status :: 5 - Production/Stable
+Classifier: License :: OSI Approved :: MIT License
+Classifier: Programming Language :: Python
+Classifier: Typing :: Typed
+Project-URL: Chat, https://discord.gg/pallets
+Project-URL: Documentation, https://blinker.readthedocs.io
+Project-URL: Source, https://github.com/pallets-eco/blinker/
+
+# Blinker
+
+Blinker provides a fast dispatching system that allows any number of
+interested parties to subscribe to events, or "signals".
+
+
+## Pallets Community Ecosystem
+
+> [!IMPORTANT]\
+> This project is part of the Pallets Community Ecosystem. Pallets is the open
+> source organization that maintains Flask; Pallets-Eco enables community
+> maintenance of related projects. If you are interested in helping maintain
+> this project, please reach out on [the Pallets Discord server][discord].
+>
+> [discord]: https://discord.gg/pallets
+
+
+## Example
+
+Signal receivers can subscribe to specific senders or receive signals
+sent by any sender.
+
+```pycon
+>>> from blinker import signal
+>>> started = signal('round-started')
+>>> def each(round):
+...     print(f"Round {round}")
+...
+>>> started.connect(each)
+
+>>> def round_two(round):
+...     print("This is round two.")
+...
+>>> started.connect(round_two, sender=2)
+
+>>> for round in range(1, 4):
+...     started.send(round)
+...
+Round 1!
+Round 2!
+This is round two.
+Round 3!
+```
+

+ 12 - 0
venv/lib/python3.9/site-packages/blinker-1.9.0.dist-info/RECORD

@@ -0,0 +1,12 @@
+blinker-1.9.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
+blinker-1.9.0.dist-info/LICENSE.txt,sha256=nrc6HzhZekqhcCXSrhvjg5Ykx5XphdTw6Xac4p-spGc,1054
+blinker-1.9.0.dist-info/METADATA,sha256=uIRiM8wjjbHkCtbCyTvctU37IAZk0kEe5kxAld1dvzA,1633
+blinker-1.9.0.dist-info/RECORD,,
+blinker-1.9.0.dist-info/WHEEL,sha256=CpUCUxeHQbRN5UGRQHYRJorO5Af-Qy_fHMctcQ8DSGI,82
+blinker/__init__.py,sha256=I2EdZqpy4LyjX17Hn1yzJGWCjeLaVaPzsMgHkLfj_cQ,317
+blinker/__pycache__/__init__.cpython-39.pyc,,
+blinker/__pycache__/_utilities.cpython-39.pyc,,
+blinker/__pycache__/base.cpython-39.pyc,,
+blinker/_utilities.py,sha256=0J7eeXXTUx0Ivf8asfpx0ycVkp0Eqfqnj117x2mYX9E,1675
+blinker/base.py,sha256=QpDuvXXcwJF49lUBcH5BiST46Rz9wSG7VW_p7N_027M,19132
+blinker/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0

+ 4 - 0
venv/lib/python3.9/site-packages/blinker-1.9.0.dist-info/WHEEL

@@ -0,0 +1,4 @@
+Wheel-Version: 1.0
+Generator: flit 3.10.1
+Root-Is-Purelib: true
+Tag: py3-none-any

+ 17 - 0
venv/lib/python3.9/site-packages/blinker/__init__.py

@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+from .base import ANY
+from .base import default_namespace
+from .base import NamedSignal
+from .base import Namespace
+from .base import Signal
+from .base import signal
+
+__all__ = [
+    "ANY",
+    "default_namespace",
+    "NamedSignal",
+    "Namespace",
+    "Signal",
+    "signal",
+]

二进制
venv/lib/python3.9/site-packages/blinker/__pycache__/__init__.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/blinker/__pycache__/_utilities.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/blinker/__pycache__/base.cpython-39.pyc


+ 64 - 0
venv/lib/python3.9/site-packages/blinker/_utilities.py

@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+import collections.abc as c
+import inspect
+import typing as t
+from weakref import ref
+from weakref import WeakMethod
+
+T = t.TypeVar("T")
+
+
+class Symbol:
+    """A constant symbol, nicer than ``object()``. Repeated calls return the
+    same instance.
+
+    >>> Symbol('foo') is Symbol('foo')
+    True
+    >>> Symbol('foo')
+    foo
+    """
+
+    symbols: t.ClassVar[dict[str, Symbol]] = {}
+
+    def __new__(cls, name: str) -> Symbol:
+        if name in cls.symbols:
+            return cls.symbols[name]
+
+        obj = super().__new__(cls)
+        cls.symbols[name] = obj
+        return obj
+
+    def __init__(self, name: str) -> None:
+        self.name = name
+
+    def __repr__(self) -> str:
+        return self.name
+
+    def __getnewargs__(self) -> tuple[t.Any, ...]:
+        return (self.name,)
+
+
+def make_id(obj: object) -> c.Hashable:
+    """Get a stable identifier for a receiver or sender, to be used as a dict
+    key or in a set.
+    """
+    if inspect.ismethod(obj):
+        # The id of a bound method is not stable, but the id of the unbound
+        # function and instance are.
+        return id(obj.__func__), id(obj.__self__)
+
+    if isinstance(obj, (str, int)):
+        # Instances with the same value always compare equal and have the same
+        # hash, even if the id may change.
+        return obj
+
+    # Assume other types are not hashable but will always be the same instance.
+    return id(obj)
+
+
+def make_ref(obj: T, callback: c.Callable[[ref[T]], None] | None = None) -> ref[T]:
+    if inspect.ismethod(obj):
+        return WeakMethod(obj, callback)  # type: ignore[arg-type, return-value]
+
+    return ref(obj, callback)

+ 512 - 0
venv/lib/python3.9/site-packages/blinker/base.py

@@ -0,0 +1,512 @@
+from __future__ import annotations
+
+import collections.abc as c
+import sys
+import typing as t
+import weakref
+from collections import defaultdict
+from contextlib import contextmanager
+from functools import cached_property
+from inspect import iscoroutinefunction
+
+from ._utilities import make_id
+from ._utilities import make_ref
+from ._utilities import Symbol
+
+F = t.TypeVar("F", bound=c.Callable[..., t.Any])
+
+ANY = Symbol("ANY")
+"""Symbol for "any sender"."""
+
+ANY_ID = 0
+
+
+class Signal:
+    """A notification emitter.
+
+    :param doc: The docstring for the signal.
+    """
+
+    ANY = ANY
+    """An alias for the :data:`~blinker.ANY` sender symbol."""
+
+    set_class: type[set[t.Any]] = set
+    """The set class to use for tracking connected receivers and senders.
+    Python's ``set`` is unordered. If receivers must be dispatched in the order
+    they were connected, an ordered set implementation can be used.
+
+    .. versionadded:: 1.7
+    """
+
+    @cached_property
+    def receiver_connected(self) -> Signal:
+        """Emitted at the end of each :meth:`connect` call.
+
+        The signal sender is the signal instance, and the :meth:`connect`
+        arguments are passed through: ``receiver``, ``sender``, and ``weak``.
+
+        .. versionadded:: 1.2
+        """
+        return Signal(doc="Emitted after a receiver connects.")
+
+    @cached_property
+    def receiver_disconnected(self) -> Signal:
+        """Emitted at the end of each :meth:`disconnect` call.
+
+        The sender is the signal instance, and the :meth:`disconnect` arguments
+        are passed through: ``receiver`` and ``sender``.
+
+        This signal is emitted **only** when :meth:`disconnect` is called
+        explicitly. This signal cannot be emitted by an automatic disconnect
+        when a weakly referenced receiver or sender goes out of scope, as the
+        instance is no longer be available to be used as the sender for this
+        signal.
+
+        An alternative approach is available by subscribing to
+        :attr:`receiver_connected` and setting up a custom weakref cleanup
+        callback on weak receivers and senders.
+
+        .. versionadded:: 1.2
+        """
+        return Signal(doc="Emitted after a receiver disconnects.")
+
+    def __init__(self, doc: str | None = None) -> None:
+        if doc:
+            self.__doc__ = doc
+
+        self.receivers: dict[
+            t.Any, weakref.ref[c.Callable[..., t.Any]] | c.Callable[..., t.Any]
+        ] = {}
+        """The map of connected receivers. Useful to quickly check if any
+        receivers are connected to the signal: ``if s.receivers:``. The
+        structure and data is not part of the public API, but checking its
+        boolean value is.
+        """
+
+        self.is_muted: bool = False
+        self._by_receiver: dict[t.Any, set[t.Any]] = defaultdict(self.set_class)
+        self._by_sender: dict[t.Any, set[t.Any]] = defaultdict(self.set_class)
+        self._weak_senders: dict[t.Any, weakref.ref[t.Any]] = {}
+
+    def connect(self, receiver: F, sender: t.Any = ANY, weak: bool = True) -> F:
+        """Connect ``receiver`` to be called when the signal is sent by
+        ``sender``.
+
+        :param receiver: The callable to call when :meth:`send` is called with
+            the given ``sender``, passing ``sender`` as a positional argument
+            along with any extra keyword arguments.
+        :param sender: Any object or :data:`ANY`. ``receiver`` will only be
+            called when :meth:`send` is called with this sender. If ``ANY``, the
+            receiver will be called for any sender. A receiver may be connected
+            to multiple senders by calling :meth:`connect` multiple times.
+        :param weak: Track the receiver with a :mod:`weakref`. The receiver will
+            be automatically disconnected when it is garbage collected. When
+            connecting a receiver defined within a function, set to ``False``,
+            otherwise it will be disconnected when the function scope ends.
+        """
+        receiver_id = make_id(receiver)
+        sender_id = ANY_ID if sender is ANY else make_id(sender)
+
+        if weak:
+            self.receivers[receiver_id] = make_ref(
+                receiver, self._make_cleanup_receiver(receiver_id)
+            )
+        else:
+            self.receivers[receiver_id] = receiver
+
+        self._by_sender[sender_id].add(receiver_id)
+        self._by_receiver[receiver_id].add(sender_id)
+
+        if sender is not ANY and sender_id not in self._weak_senders:
+            # store a cleanup for weakref-able senders
+            try:
+                self._weak_senders[sender_id] = make_ref(
+                    sender, self._make_cleanup_sender(sender_id)
+                )
+            except TypeError:
+                pass
+
+        if "receiver_connected" in self.__dict__ and self.receiver_connected.receivers:
+            try:
+                self.receiver_connected.send(
+                    self, receiver=receiver, sender=sender, weak=weak
+                )
+            except TypeError:
+                # TODO no explanation or test for this
+                self.disconnect(receiver, sender)
+                raise
+
+        return receiver
+
+    def connect_via(self, sender: t.Any, weak: bool = False) -> c.Callable[[F], F]:
+        """Connect the decorated function to be called when the signal is sent
+        by ``sender``.
+
+        The decorated function will be called when :meth:`send` is called with
+        the given ``sender``, passing ``sender`` as a positional argument along
+        with any extra keyword arguments.
+
+        :param sender: Any object or :data:`ANY`. ``receiver`` will only be
+            called when :meth:`send` is called with this sender. If ``ANY``, the
+            receiver will be called for any sender. A receiver may be connected
+            to multiple senders by calling :meth:`connect` multiple times.
+        :param weak: Track the receiver with a :mod:`weakref`. The receiver will
+            be automatically disconnected when it is garbage collected. When
+            connecting a receiver defined within a function, set to ``False``,
+            otherwise it will be disconnected when the function scope ends.=
+
+        .. versionadded:: 1.1
+        """
+
+        def decorator(fn: F) -> F:
+            self.connect(fn, sender, weak)
+            return fn
+
+        return decorator
+
+    @contextmanager
+    def connected_to(
+        self, receiver: c.Callable[..., t.Any], sender: t.Any = ANY
+    ) -> c.Generator[None, None, None]:
+        """A context manager that temporarily connects ``receiver`` to the
+        signal while a ``with`` block executes. When the block exits, the
+        receiver is disconnected. Useful for tests.
+
+        :param receiver: The callable to call when :meth:`send` is called with
+            the given ``sender``, passing ``sender`` as a positional argument
+            along with any extra keyword arguments.
+        :param sender: Any object or :data:`ANY`. ``receiver`` will only be
+            called when :meth:`send` is called with this sender. If ``ANY``, the
+            receiver will be called for any sender.
+
+        .. versionadded:: 1.1
+        """
+        self.connect(receiver, sender=sender, weak=False)
+
+        try:
+            yield None
+        finally:
+            self.disconnect(receiver)
+
+    @contextmanager
+    def muted(self) -> c.Generator[None, None, None]:
+        """A context manager that temporarily disables the signal. No receivers
+        will be called if the signal is sent, until the ``with`` block exits.
+        Useful for tests.
+        """
+        self.is_muted = True
+
+        try:
+            yield None
+        finally:
+            self.is_muted = False
+
+    def send(
+        self,
+        sender: t.Any | None = None,
+        /,
+        *,
+        _async_wrapper: c.Callable[
+            [c.Callable[..., c.Coroutine[t.Any, t.Any, t.Any]]], c.Callable[..., t.Any]
+        ]
+        | None = None,
+        **kwargs: t.Any,
+    ) -> list[tuple[c.Callable[..., t.Any], t.Any]]:
+        """Call all receivers that are connected to the given ``sender``
+        or :data:`ANY`. Each receiver is called with ``sender`` as a positional
+        argument along with any extra keyword arguments. Return a list of
+        ``(receiver, return value)`` tuples.
+
+        The order receivers are called is undefined, but can be influenced by
+        setting :attr:`set_class`.
+
+        If a receiver raises an exception, that exception will propagate up.
+        This makes debugging straightforward, with an assumption that correctly
+        implemented receivers will not raise.
+
+        :param sender: Call receivers connected to this sender, in addition to
+            those connected to :data:`ANY`.
+        :param _async_wrapper: Will be called on any receivers that are async
+            coroutines to turn them into sync callables. For example, could run
+            the receiver with an event loop.
+        :param kwargs: Extra keyword arguments to pass to each receiver.
+
+        .. versionchanged:: 1.7
+            Added the ``_async_wrapper`` argument.
+        """
+        if self.is_muted:
+            return []
+
+        results = []
+
+        for receiver in self.receivers_for(sender):
+            if iscoroutinefunction(receiver):
+                if _async_wrapper is None:
+                    raise RuntimeError("Cannot send to a coroutine function.")
+
+                result = _async_wrapper(receiver)(sender, **kwargs)
+            else:
+                result = receiver(sender, **kwargs)
+
+            results.append((receiver, result))
+
+        return results
+
+    async def send_async(
+        self,
+        sender: t.Any | None = None,
+        /,
+        *,
+        _sync_wrapper: c.Callable[
+            [c.Callable[..., t.Any]], c.Callable[..., c.Coroutine[t.Any, t.Any, t.Any]]
+        ]
+        | None = None,
+        **kwargs: t.Any,
+    ) -> list[tuple[c.Callable[..., t.Any], t.Any]]:
+        """Await all receivers that are connected to the given ``sender``
+        or :data:`ANY`. Each receiver is called with ``sender`` as a positional
+        argument along with any extra keyword arguments. Return a list of
+        ``(receiver, return value)`` tuples.
+
+        The order receivers are called is undefined, but can be influenced by
+        setting :attr:`set_class`.
+
+        If a receiver raises an exception, that exception will propagate up.
+        This makes debugging straightforward, with an assumption that correctly
+        implemented receivers will not raise.
+
+        :param sender: Call receivers connected to this sender, in addition to
+            those connected to :data:`ANY`.
+        :param _sync_wrapper: Will be called on any receivers that are sync
+            callables to turn them into async coroutines. For example,
+            could call the receiver in a thread.
+        :param kwargs: Extra keyword arguments to pass to each receiver.
+
+        .. versionadded:: 1.7
+        """
+        if self.is_muted:
+            return []
+
+        results = []
+
+        for receiver in self.receivers_for(sender):
+            if not iscoroutinefunction(receiver):
+                if _sync_wrapper is None:
+                    raise RuntimeError("Cannot send to a non-coroutine function.")
+
+                result = await _sync_wrapper(receiver)(sender, **kwargs)
+            else:
+                result = await receiver(sender, **kwargs)
+
+            results.append((receiver, result))
+
+        return results
+
+    def has_receivers_for(self, sender: t.Any) -> bool:
+        """Check if there is at least one receiver that will be called with the
+        given ``sender``. A receiver connected to :data:`ANY` will always be
+        called, regardless of sender. Does not check if weakly referenced
+        receivers are still live. See :meth:`receivers_for` for a stronger
+        search.
+
+        :param sender: Check for receivers connected to this sender, in addition
+            to those connected to :data:`ANY`.
+        """
+        if not self.receivers:
+            return False
+
+        if self._by_sender[ANY_ID]:
+            return True
+
+        if sender is ANY:
+            return False
+
+        return make_id(sender) in self._by_sender
+
+    def receivers_for(
+        self, sender: t.Any
+    ) -> c.Generator[c.Callable[..., t.Any], None, None]:
+        """Yield each receiver to be called for ``sender``, in addition to those
+        to be called for :data:`ANY`. Weakly referenced receivers that are not
+        live will be disconnected and skipped.
+
+        :param sender: Yield receivers connected to this sender, in addition
+            to those connected to :data:`ANY`.
+        """
+        # TODO: test receivers_for(ANY)
+        if not self.receivers:
+            return
+
+        sender_id = make_id(sender)
+
+        if sender_id in self._by_sender:
+            ids = self._by_sender[ANY_ID] | self._by_sender[sender_id]
+        else:
+            ids = self._by_sender[ANY_ID].copy()
+
+        for receiver_id in ids:
+            receiver = self.receivers.get(receiver_id)
+
+            if receiver is None:
+                continue
+
+            if isinstance(receiver, weakref.ref):
+                strong = receiver()
+
+                if strong is None:
+                    self._disconnect(receiver_id, ANY_ID)
+                    continue
+
+                yield strong
+            else:
+                yield receiver
+
+    def disconnect(self, receiver: c.Callable[..., t.Any], sender: t.Any = ANY) -> None:
+        """Disconnect ``receiver`` from being called when the signal is sent by
+        ``sender``.
+
+        :param receiver: A connected receiver callable.
+        :param sender: Disconnect from only this sender. By default, disconnect
+            from all senders.
+        """
+        sender_id: c.Hashable
+
+        if sender is ANY:
+            sender_id = ANY_ID
+        else:
+            sender_id = make_id(sender)
+
+        receiver_id = make_id(receiver)
+        self._disconnect(receiver_id, sender_id)
+
+        if (
+            "receiver_disconnected" in self.__dict__
+            and self.receiver_disconnected.receivers
+        ):
+            self.receiver_disconnected.send(self, receiver=receiver, sender=sender)
+
+    def _disconnect(self, receiver_id: c.Hashable, sender_id: c.Hashable) -> None:
+        if sender_id == ANY_ID:
+            if self._by_receiver.pop(receiver_id, None) is not None:
+                for bucket in self._by_sender.values():
+                    bucket.discard(receiver_id)
+
+            self.receivers.pop(receiver_id, None)
+        else:
+            self._by_sender[sender_id].discard(receiver_id)
+            self._by_receiver[receiver_id].discard(sender_id)
+
+    def _make_cleanup_receiver(
+        self, receiver_id: c.Hashable
+    ) -> c.Callable[[weakref.ref[c.Callable[..., t.Any]]], None]:
+        """Create a callback function to disconnect a weakly referenced
+        receiver when it is garbage collected.
+        """
+
+        def cleanup(ref: weakref.ref[c.Callable[..., t.Any]]) -> None:
+            # If the interpreter is shutting down, disconnecting can result in a
+            # weird ignored exception. Don't call it in that case.
+            if not sys.is_finalizing():
+                self._disconnect(receiver_id, ANY_ID)
+
+        return cleanup
+
+    def _make_cleanup_sender(
+        self, sender_id: c.Hashable
+    ) -> c.Callable[[weakref.ref[t.Any]], None]:
+        """Create a callback function to disconnect all receivers for a weakly
+        referenced sender when it is garbage collected.
+        """
+        assert sender_id != ANY_ID
+
+        def cleanup(ref: weakref.ref[t.Any]) -> None:
+            self._weak_senders.pop(sender_id, None)
+
+            for receiver_id in self._by_sender.pop(sender_id, ()):
+                self._by_receiver[receiver_id].discard(sender_id)
+
+        return cleanup
+
+    def _cleanup_bookkeeping(self) -> None:
+        """Prune unused sender/receiver bookkeeping. Not threadsafe.
+
+        Connecting & disconnecting leaves behind a small amount of bookkeeping
+        data. Typical workloads using Blinker, for example in most web apps,
+        Flask, CLI scripts, etc., are not adversely affected by this
+        bookkeeping.
+
+        With a long-running process performing dynamic signal routing with high
+        volume, e.g. connecting to function closures, senders are all unique
+        object instances. Doing all of this over and over may cause memory usage
+        to grow due to extraneous bookkeeping. (An empty ``set`` for each stale
+        sender/receiver pair.)
+
+        This method will prune that bookkeeping away, with the caveat that such
+        pruning is not threadsafe. The risk is that cleanup of a fully
+        disconnected receiver/sender pair occurs while another thread is
+        connecting that same pair. If you are in the highly dynamic, unique
+        receiver/sender situation that has lead you to this method, that failure
+        mode is perhaps not a big deal for you.
+        """
+        for mapping in (self._by_sender, self._by_receiver):
+            for ident, bucket in list(mapping.items()):
+                if not bucket:
+                    mapping.pop(ident, None)
+
+    def _clear_state(self) -> None:
+        """Disconnect all receivers and senders. Useful for tests."""
+        self._weak_senders.clear()
+        self.receivers.clear()
+        self._by_sender.clear()
+        self._by_receiver.clear()
+
+
+class NamedSignal(Signal):
+    """A named generic notification emitter. The name is not used by the signal
+    itself, but matches the key in the :class:`Namespace` that it belongs to.
+
+    :param name: The name of the signal within the namespace.
+    :param doc: The docstring for the signal.
+    """
+
+    def __init__(self, name: str, doc: str | None = None) -> None:
+        super().__init__(doc)
+
+        #: The name of this signal.
+        self.name: str = name
+
+    def __repr__(self) -> str:
+        base = super().__repr__()
+        return f"{base[:-1]}; {self.name!r}>"  # noqa: E702
+
+
+class Namespace(dict[str, NamedSignal]):
+    """A dict mapping names to signals."""
+
+    def signal(self, name: str, doc: str | None = None) -> NamedSignal:
+        """Return the :class:`NamedSignal` for the given ``name``, creating it
+        if required. Repeated calls with the same name return the same signal.
+
+        :param name: The name of the signal.
+        :param doc: The docstring of the signal.
+        """
+        if name not in self:
+            self[name] = NamedSignal(name, doc)
+
+        return self[name]
+
+
+class _PNamespaceSignal(t.Protocol):
+    def __call__(self, name: str, doc: str | None = None) -> NamedSignal: ...
+
+
+default_namespace: Namespace = Namespace()
+"""A default :class:`Namespace` for creating named signals. :func:`signal`
+creates a :class:`NamedSignal` in this namespace.
+"""
+
+signal: _PNamespaceSignal = default_namespace.signal
+"""Return a :class:`NamedSignal` in :data:`default_namespace` with the given
+``name``, creating it if required. Repeated calls with the same name return the
+same signal.
+"""

+ 0 - 0
venv/lib/python3.9/site-packages/blinker/py.typed


+ 1 - 0
venv/lib/python3.9/site-packages/click-8.1.8.dist-info/INSTALLER

@@ -0,0 +1 @@
+pip

+ 28 - 0
venv/lib/python3.9/site-packages/click-8.1.8.dist-info/LICENSE.txt

@@ -0,0 +1,28 @@
+Copyright 2014 Pallets
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+1.  Redistributions of source code must retain the above copyright
+    notice, this list of conditions and the following disclaimer.
+
+2.  Redistributions in binary form must reproduce the above copyright
+    notice, this list of conditions and the following disclaimer in the
+    documentation and/or other materials provided with the distribution.
+
+3.  Neither the name of the copyright holder nor the names of its
+    contributors may be used to endorse or promote products derived from
+    this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 74 - 0
venv/lib/python3.9/site-packages/click-8.1.8.dist-info/METADATA

@@ -0,0 +1,74 @@
+Metadata-Version: 2.3
+Name: click
+Version: 8.1.8
+Summary: Composable command line interface toolkit
+Maintainer-email: Pallets <contact@palletsprojects.com>
+Requires-Python: >=3.7
+Description-Content-Type: text/markdown
+Classifier: Development Status :: 5 - Production/Stable
+Classifier: Intended Audience :: Developers
+Classifier: License :: OSI Approved :: BSD License
+Classifier: Operating System :: OS Independent
+Classifier: Programming Language :: Python
+Classifier: Typing :: Typed
+Requires-Dist: colorama; platform_system == 'Windows'
+Requires-Dist: importlib-metadata; python_version < '3.8'
+Project-URL: Changes, https://click.palletsprojects.com/changes/
+Project-URL: Chat, https://discord.gg/pallets
+Project-URL: Documentation, https://click.palletsprojects.com/
+Project-URL: Donate, https://palletsprojects.com/donate
+Project-URL: Source, https://github.com/pallets/click/
+
+# $ click_
+
+Click is a Python package for creating beautiful command line interfaces
+in a composable way with as little code as necessary. It's the "Command
+Line Interface Creation Kit". It's highly configurable but comes with
+sensible defaults out of the box.
+
+It aims to make the process of writing command line tools quick and fun
+while also preventing any frustration caused by the inability to
+implement an intended CLI API.
+
+Click in three points:
+
+-   Arbitrary nesting of commands
+-   Automatic help page generation
+-   Supports lazy loading of subcommands at runtime
+
+
+## A Simple Example
+
+```python
+import click
+
+@click.command()
+@click.option("--count", default=1, help="Number of greetings.")
+@click.option("--name", prompt="Your name", help="The person to greet.")
+def hello(count, name):
+    """Simple program that greets NAME for a total of COUNT times."""
+    for _ in range(count):
+        click.echo(f"Hello, {name}!")
+
+if __name__ == '__main__':
+    hello()
+```
+
+```
+$ python hello.py --count=3
+Your name: Click
+Hello, Click!
+Hello, Click!
+Hello, Click!
+```
+
+
+## Donate
+
+The Pallets organization develops and supports Click and other popular
+packages. In order to grow the community of contributors and users, and
+allow the maintainers to devote more time to the projects, [please
+donate today][].
+
+[please donate today]: https://palletsprojects.com/donate
+

+ 38 - 0
venv/lib/python3.9/site-packages/click-8.1.8.dist-info/RECORD

@@ -0,0 +1,38 @@
+click-8.1.8.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
+click-8.1.8.dist-info/LICENSE.txt,sha256=morRBqOU6FO_4h9C9OctWSgZoigF2ZG18ydQKSkrZY0,1475
+click-8.1.8.dist-info/METADATA,sha256=WJtQ6uGS2ybLfvUE4vC0XIhIBr4yFGwjrMBR2fiCQ-Q,2263
+click-8.1.8.dist-info/RECORD,,
+click-8.1.8.dist-info/WHEEL,sha256=CpUCUxeHQbRN5UGRQHYRJorO5Af-Qy_fHMctcQ8DSGI,82
+click/__init__.py,sha256=j1DJeCbga4ribkv5uyvIAzI0oFN13fW9mevDKShFelo,3188
+click/__pycache__/__init__.cpython-39.pyc,,
+click/__pycache__/_compat.cpython-39.pyc,,
+click/__pycache__/_termui_impl.cpython-39.pyc,,
+click/__pycache__/_textwrap.cpython-39.pyc,,
+click/__pycache__/_winconsole.cpython-39.pyc,,
+click/__pycache__/core.cpython-39.pyc,,
+click/__pycache__/decorators.cpython-39.pyc,,
+click/__pycache__/exceptions.cpython-39.pyc,,
+click/__pycache__/formatting.cpython-39.pyc,,
+click/__pycache__/globals.cpython-39.pyc,,
+click/__pycache__/parser.cpython-39.pyc,,
+click/__pycache__/shell_completion.cpython-39.pyc,,
+click/__pycache__/termui.cpython-39.pyc,,
+click/__pycache__/testing.cpython-39.pyc,,
+click/__pycache__/types.cpython-39.pyc,,
+click/__pycache__/utils.cpython-39.pyc,,
+click/_compat.py,sha256=IGKh_J5QdfKELitnRfTGHneejWxoCw_NX9tfMbdcg3w,18730
+click/_termui_impl.py,sha256=a5z7I9gOFeMmu7Gb6_RPyQ8GPuVP1EeblixcWSPSQPk,24783
+click/_textwrap.py,sha256=10fQ64OcBUMuK7mFvh8363_uoOxPlRItZBmKzRJDgoY,1353
+click/_winconsole.py,sha256=5ju3jQkcZD0W27WEMGqmEP4y_crUVzPCqsX_FYb7BO0,7860
+click/core.py,sha256=Q1nEVdctZwvIPOlt4vfHko0TYnHCeE40UEEul8Wpyvs,114748
+click/decorators.py,sha256=7t6F-QWowtLh6F_6l-4YV4Y4yNTcqFQEu9i37zIz68s,18925
+click/exceptions.py,sha256=V7zDT6emqJ8iNl0kF1P5kpFmLMWQ1T1L7aNNKM4YR0w,9600
+click/formatting.py,sha256=Frf0-5W33-loyY_i9qrwXR8-STnW3m5gvyxLVUdyxyk,9706
+click/globals.py,sha256=cuJ6Bbo073lgEEmhjr394PeM-QFmXM-Ci-wmfsd7H5g,1954
+click/parser.py,sha256=h4sndcpF5OHrZQN8vD8IWb5OByvW7ABbhRToxovrqS8,19067
+click/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+click/shell_completion.py,sha256=TR0dXEGcvWb9Eo3aaQEXGhnvNS3FF4H4QcuLnvAvYo4,18636
+click/termui.py,sha256=dLxiS70UOvIYBda_nEEZaPAFOVDVmRs1sEPMuLDowQo,28310
+click/testing.py,sha256=3RA8anCf7TZ8-5RAF5it2Te-aWXBAL5VLasQnMiC2ZQ,16282
+click/types.py,sha256=BD5Qqq4h-8kawBmOIzJlmq4xzThAf4wCvaOLZSBDNx0,36422
+click/utils.py,sha256=ce-IrO9ilII76LGkU354pOdHbepM8UftfNH7SfMU_28,20330

+ 4 - 0
venv/lib/python3.9/site-packages/click-8.1.8.dist-info/WHEEL

@@ -0,0 +1,4 @@
+Wheel-Version: 1.0
+Generator: flit 3.10.1
+Root-Is-Purelib: true
+Tag: py3-none-any

+ 75 - 0
venv/lib/python3.9/site-packages/click/__init__.py

@@ -0,0 +1,75 @@
+"""
+Click is a simple Python module inspired by the stdlib optparse to make
+writing command line scripts fun. Unlike other modules, it's based
+around a simple API that does not come with too much magic and is
+composable.
+"""
+
+from .core import Argument as Argument
+from .core import BaseCommand as BaseCommand
+from .core import Command as Command
+from .core import CommandCollection as CommandCollection
+from .core import Context as Context
+from .core import Group as Group
+from .core import MultiCommand as MultiCommand
+from .core import Option as Option
+from .core import Parameter as Parameter
+from .decorators import argument as argument
+from .decorators import command as command
+from .decorators import confirmation_option as confirmation_option
+from .decorators import group as group
+from .decorators import help_option as help_option
+from .decorators import HelpOption as HelpOption
+from .decorators import make_pass_decorator as make_pass_decorator
+from .decorators import option as option
+from .decorators import pass_context as pass_context
+from .decorators import pass_obj as pass_obj
+from .decorators import password_option as password_option
+from .decorators import version_option as version_option
+from .exceptions import Abort as Abort
+from .exceptions import BadArgumentUsage as BadArgumentUsage
+from .exceptions import BadOptionUsage as BadOptionUsage
+from .exceptions import BadParameter as BadParameter
+from .exceptions import ClickException as ClickException
+from .exceptions import FileError as FileError
+from .exceptions import MissingParameter as MissingParameter
+from .exceptions import NoSuchOption as NoSuchOption
+from .exceptions import UsageError as UsageError
+from .formatting import HelpFormatter as HelpFormatter
+from .formatting import wrap_text as wrap_text
+from .globals import get_current_context as get_current_context
+from .parser import OptionParser as OptionParser
+from .termui import clear as clear
+from .termui import confirm as confirm
+from .termui import echo_via_pager as echo_via_pager
+from .termui import edit as edit
+from .termui import getchar as getchar
+from .termui import launch as launch
+from .termui import pause as pause
+from .termui import progressbar as progressbar
+from .termui import prompt as prompt
+from .termui import secho as secho
+from .termui import style as style
+from .termui import unstyle as unstyle
+from .types import BOOL as BOOL
+from .types import Choice as Choice
+from .types import DateTime as DateTime
+from .types import File as File
+from .types import FLOAT as FLOAT
+from .types import FloatRange as FloatRange
+from .types import INT as INT
+from .types import IntRange as IntRange
+from .types import ParamType as ParamType
+from .types import Path as Path
+from .types import STRING as STRING
+from .types import Tuple as Tuple
+from .types import UNPROCESSED as UNPROCESSED
+from .types import UUID as UUID
+from .utils import echo as echo
+from .utils import format_filename as format_filename
+from .utils import get_app_dir as get_app_dir
+from .utils import get_binary_stream as get_binary_stream
+from .utils import get_text_stream as get_text_stream
+from .utils import open_file as open_file
+
+__version__ = "8.1.8"

二进制
venv/lib/python3.9/site-packages/click/__pycache__/__init__.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/click/__pycache__/_compat.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/click/__pycache__/_termui_impl.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/click/__pycache__/_textwrap.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/click/__pycache__/_winconsole.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/click/__pycache__/core.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/click/__pycache__/decorators.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/click/__pycache__/exceptions.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/click/__pycache__/formatting.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/click/__pycache__/globals.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/click/__pycache__/parser.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/click/__pycache__/shell_completion.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/click/__pycache__/termui.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/click/__pycache__/testing.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/click/__pycache__/types.cpython-39.pyc


二进制
venv/lib/python3.9/site-packages/click/__pycache__/utils.cpython-39.pyc


+ 623 - 0
venv/lib/python3.9/site-packages/click/_compat.py

@@ -0,0 +1,623 @@
+import codecs
+import io
+import os
+import re
+import sys
+import typing as t
+from weakref import WeakKeyDictionary
+
+CYGWIN = sys.platform.startswith("cygwin")
+WIN = sys.platform.startswith("win")
+auto_wrap_for_ansi: t.Optional[t.Callable[[t.TextIO], t.TextIO]] = None
+_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]")
+
+
+def _make_text_stream(
+    stream: t.BinaryIO,
+    encoding: t.Optional[str],
+    errors: t.Optional[str],
+    force_readable: bool = False,
+    force_writable: bool = False,
+) -> t.TextIO:
+    if encoding is None:
+        encoding = get_best_encoding(stream)
+    if errors is None:
+        errors = "replace"
+    return _NonClosingTextIOWrapper(
+        stream,
+        encoding,
+        errors,
+        line_buffering=True,
+        force_readable=force_readable,
+        force_writable=force_writable,
+    )
+
+
+def is_ascii_encoding(encoding: str) -> bool:
+    """Checks if a given encoding is ascii."""
+    try:
+        return codecs.lookup(encoding).name == "ascii"
+    except LookupError:
+        return False
+
+
+def get_best_encoding(stream: t.IO[t.Any]) -> str:
+    """Returns the default stream encoding if not found."""
+    rv = getattr(stream, "encoding", None) or sys.getdefaultencoding()
+    if is_ascii_encoding(rv):
+        return "utf-8"
+    return rv
+
+
+class _NonClosingTextIOWrapper(io.TextIOWrapper):
+    def __init__(
+        self,
+        stream: t.BinaryIO,
+        encoding: t.Optional[str],
+        errors: t.Optional[str],
+        force_readable: bool = False,
+        force_writable: bool = False,
+        **extra: t.Any,
+    ) -> None:
+        self._stream = stream = t.cast(
+            t.BinaryIO, _FixupStream(stream, force_readable, force_writable)
+        )
+        super().__init__(stream, encoding, errors, **extra)
+
+    def __del__(self) -> None:
+        try:
+            self.detach()
+        except Exception:
+            pass
+
+    def isatty(self) -> bool:
+        # https://bitbucket.org/pypy/pypy/issue/1803
+        return self._stream.isatty()
+
+
+class _FixupStream:
+    """The new io interface needs more from streams than streams
+    traditionally implement.  As such, this fix-up code is necessary in
+    some circumstances.
+
+    The forcing of readable and writable flags are there because some tools
+    put badly patched objects on sys (one such offender are certain version
+    of jupyter notebook).
+    """
+
+    def __init__(
+        self,
+        stream: t.BinaryIO,
+        force_readable: bool = False,
+        force_writable: bool = False,
+    ):
+        self._stream = stream
+        self._force_readable = force_readable
+        self._force_writable = force_writable
+
+    def __getattr__(self, name: str) -> t.Any:
+        return getattr(self._stream, name)
+
+    def read1(self, size: int) -> bytes:
+        f = getattr(self._stream, "read1", None)
+
+        if f is not None:
+            return t.cast(bytes, f(size))
+
+        return self._stream.read(size)
+
+    def readable(self) -> bool:
+        if self._force_readable:
+            return True
+        x = getattr(self._stream, "readable", None)
+        if x is not None:
+            return t.cast(bool, x())
+        try:
+            self._stream.read(0)
+        except Exception:
+            return False
+        return True
+
+    def writable(self) -> bool:
+        if self._force_writable:
+            return True
+        x = getattr(self._stream, "writable", None)
+        if x is not None:
+            return t.cast(bool, x())
+        try:
+            self._stream.write("")  # type: ignore
+        except Exception:
+            try:
+                self._stream.write(b"")
+            except Exception:
+                return False
+        return True
+
+    def seekable(self) -> bool:
+        x = getattr(self._stream, "seekable", None)
+        if x is not None:
+            return t.cast(bool, x())
+        try:
+            self._stream.seek(self._stream.tell())
+        except Exception:
+            return False
+        return True
+
+
+def _is_binary_reader(stream: t.IO[t.Any], default: bool = False) -> bool:
+    try:
+        return isinstance(stream.read(0), bytes)
+    except Exception:
+        return default
+        # This happens in some cases where the stream was already
+        # closed.  In this case, we assume the default.
+
+
+def _is_binary_writer(stream: t.IO[t.Any], default: bool = False) -> bool:
+    try:
+        stream.write(b"")
+    except Exception:
+        try:
+            stream.write("")
+            return False
+        except Exception:
+            pass
+        return default
+    return True
+
+
+def _find_binary_reader(stream: t.IO[t.Any]) -> t.Optional[t.BinaryIO]:
+    # We need to figure out if the given stream is already binary.
+    # This can happen because the official docs recommend detaching
+    # the streams to get binary streams.  Some code might do this, so
+    # we need to deal with this case explicitly.
+    if _is_binary_reader(stream, False):
+        return t.cast(t.BinaryIO, stream)
+
+    buf = getattr(stream, "buffer", None)
+
+    # Same situation here; this time we assume that the buffer is
+    # actually binary in case it's closed.
+    if buf is not None and _is_binary_reader(buf, True):
+        return t.cast(t.BinaryIO, buf)
+
+    return None
+
+
+def _find_binary_writer(stream: t.IO[t.Any]) -> t.Optional[t.BinaryIO]:
+    # We need to figure out if the given stream is already binary.
+    # This can happen because the official docs recommend detaching
+    # the streams to get binary streams.  Some code might do this, so
+    # we need to deal with this case explicitly.
+    if _is_binary_writer(stream, False):
+        return t.cast(t.BinaryIO, stream)
+
+    buf = getattr(stream, "buffer", None)
+
+    # Same situation here; this time we assume that the buffer is
+    # actually binary in case it's closed.
+    if buf is not None and _is_binary_writer(buf, True):
+        return t.cast(t.BinaryIO, buf)
+
+    return None
+
+
+def _stream_is_misconfigured(stream: t.TextIO) -> bool:
+    """A stream is misconfigured if its encoding is ASCII."""
+    # If the stream does not have an encoding set, we assume it's set
+    # to ASCII.  This appears to happen in certain unittest
+    # environments.  It's not quite clear what the correct behavior is
+    # but this at least will force Click to recover somehow.
+    return is_ascii_encoding(getattr(stream, "encoding", None) or "ascii")
+
+
+def _is_compat_stream_attr(stream: t.TextIO, attr: str, value: t.Optional[str]) -> bool:
+    """A stream attribute is compatible if it is equal to the
+    desired value or the desired value is unset and the attribute
+    has a value.
+    """
+    stream_value = getattr(stream, attr, None)
+    return stream_value == value or (value is None and stream_value is not None)
+
+
+def _is_compatible_text_stream(
+    stream: t.TextIO, encoding: t.Optional[str], errors: t.Optional[str]
+) -> bool:
+    """Check if a stream's encoding and errors attributes are
+    compatible with the desired values.
+    """
+    return _is_compat_stream_attr(
+        stream, "encoding", encoding
+    ) and _is_compat_stream_attr(stream, "errors", errors)
+
+
+def _force_correct_text_stream(
+    text_stream: t.IO[t.Any],
+    encoding: t.Optional[str],
+    errors: t.Optional[str],
+    is_binary: t.Callable[[t.IO[t.Any], bool], bool],
+    find_binary: t.Callable[[t.IO[t.Any]], t.Optional[t.BinaryIO]],
+    force_readable: bool = False,
+    force_writable: bool = False,
+) -> t.TextIO:
+    if is_binary(text_stream, False):
+        binary_reader = t.cast(t.BinaryIO, text_stream)
+    else:
+        text_stream = t.cast(t.TextIO, text_stream)
+        # If the stream looks compatible, and won't default to a
+        # misconfigured ascii encoding, return it as-is.
+        if _is_compatible_text_stream(text_stream, encoding, errors) and not (
+            encoding is None and _stream_is_misconfigured(text_stream)
+        ):
+            return text_stream
+
+        # Otherwise, get the underlying binary reader.
+        possible_binary_reader = find_binary(text_stream)
+
+        # If that's not possible, silently use the original reader
+        # and get mojibake instead of exceptions.
+        if possible_binary_reader is None:
+            return text_stream
+
+        binary_reader = possible_binary_reader
+
+    # Default errors to replace instead of strict in order to get
+    # something that works.
+    if errors is None:
+        errors = "replace"
+
+    # Wrap the binary stream in a text stream with the correct
+    # encoding parameters.
+    return _make_text_stream(
+        binary_reader,
+        encoding,
+        errors,
+        force_readable=force_readable,
+        force_writable=force_writable,
+    )
+
+
+def _force_correct_text_reader(
+    text_reader: t.IO[t.Any],
+    encoding: t.Optional[str],
+    errors: t.Optional[str],
+    force_readable: bool = False,
+) -> t.TextIO:
+    return _force_correct_text_stream(
+        text_reader,
+        encoding,
+        errors,
+        _is_binary_reader,
+        _find_binary_reader,
+        force_readable=force_readable,
+    )
+
+
+def _force_correct_text_writer(
+    text_writer: t.IO[t.Any],
+    encoding: t.Optional[str],
+    errors: t.Optional[str],
+    force_writable: bool = False,
+) -> t.TextIO:
+    return _force_correct_text_stream(
+        text_writer,
+        encoding,
+        errors,
+        _is_binary_writer,
+        _find_binary_writer,
+        force_writable=force_writable,
+    )
+
+
+def get_binary_stdin() -> t.BinaryIO:
+    reader = _find_binary_reader(sys.stdin)
+    if reader is None:
+        raise RuntimeError("Was not able to determine binary stream for sys.stdin.")
+    return reader
+
+
+def get_binary_stdout() -> t.BinaryIO:
+    writer = _find_binary_writer(sys.stdout)
+    if writer is None:
+        raise RuntimeError("Was not able to determine binary stream for sys.stdout.")
+    return writer
+
+
+def get_binary_stderr() -> t.BinaryIO:
+    writer = _find_binary_writer(sys.stderr)
+    if writer is None:
+        raise RuntimeError("Was not able to determine binary stream for sys.stderr.")
+    return writer
+
+
+def get_text_stdin(
+    encoding: t.Optional[str] = None, errors: t.Optional[str] = None
+) -> t.TextIO:
+    rv = _get_windows_console_stream(sys.stdin, encoding, errors)
+    if rv is not None:
+        return rv
+    return _force_correct_text_reader(sys.stdin, encoding, errors, force_readable=True)
+
+
+def get_text_stdout(
+    encoding: t.Optional[str] = None, errors: t.Optional[str] = None
+) -> t.TextIO:
+    rv = _get_windows_console_stream(sys.stdout, encoding, errors)
+    if rv is not None:
+        return rv
+    return _force_correct_text_writer(sys.stdout, encoding, errors, force_writable=True)
+
+
+def get_text_stderr(
+    encoding: t.Optional[str] = None, errors: t.Optional[str] = None
+) -> t.TextIO:
+    rv = _get_windows_console_stream(sys.stderr, encoding, errors)
+    if rv is not None:
+        return rv
+    return _force_correct_text_writer(sys.stderr, encoding, errors, force_writable=True)
+
+
+def _wrap_io_open(
+    file: t.Union[str, "os.PathLike[str]", int],
+    mode: str,
+    encoding: t.Optional[str],
+    errors: t.Optional[str],
+) -> t.IO[t.Any]:
+    """Handles not passing ``encoding`` and ``errors`` in binary mode."""
+    if "b" in mode:
+        return open(file, mode)
+
+    return open(file, mode, encoding=encoding, errors=errors)
+
+
+def open_stream(
+    filename: "t.Union[str, os.PathLike[str]]",
+    mode: str = "r",
+    encoding: t.Optional[str] = None,
+    errors: t.Optional[str] = "strict",
+    atomic: bool = False,
+) -> t.Tuple[t.IO[t.Any], bool]:
+    binary = "b" in mode
+    filename = os.fspath(filename)
+
+    # Standard streams first. These are simple because they ignore the
+    # atomic flag. Use fsdecode to handle Path("-").
+    if os.fsdecode(filename) == "-":
+        if any(m in mode for m in ["w", "a", "x"]):
+            if binary:
+                return get_binary_stdout(), False
+            return get_text_stdout(encoding=encoding, errors=errors), False
+        if binary:
+            return get_binary_stdin(), False
+        return get_text_stdin(encoding=encoding, errors=errors), False
+
+    # Non-atomic writes directly go out through the regular open functions.
+    if not atomic:
+        return _wrap_io_open(filename, mode, encoding, errors), True
+
+    # Some usability stuff for atomic writes
+    if "a" in mode:
+        raise ValueError(
+            "Appending to an existing file is not supported, because that"
+            " would involve an expensive `copy`-operation to a temporary"
+            " file. Open the file in normal `w`-mode and copy explicitly"
+            " if that's what you're after."
+        )
+    if "x" in mode:
+        raise ValueError("Use the `overwrite`-parameter instead.")
+    if "w" not in mode:
+        raise ValueError("Atomic writes only make sense with `w`-mode.")
+
+    # Atomic writes are more complicated.  They work by opening a file
+    # as a proxy in the same folder and then using the fdopen
+    # functionality to wrap it in a Python file.  Then we wrap it in an
+    # atomic file that moves the file over on close.
+    import errno
+    import random
+
+    try:
+        perm: t.Optional[int] = os.stat(filename).st_mode
+    except OSError:
+        perm = None
+
+    flags = os.O_RDWR | os.O_CREAT | os.O_EXCL
+
+    if binary:
+        flags |= getattr(os, "O_BINARY", 0)
+
+    while True:
+        tmp_filename = os.path.join(
+            os.path.dirname(filename),
+            f".__atomic-write{random.randrange(1 << 32):08x}",
+        )
+        try:
+            fd = os.open(tmp_filename, flags, 0o666 if perm is None else perm)
+            break
+        except OSError as e:
+            if e.errno == errno.EEXIST or (
+                os.name == "nt"
+                and e.errno == errno.EACCES
+                and os.path.isdir(e.filename)
+                and os.access(e.filename, os.W_OK)
+            ):
+                continue
+            raise
+
+    if perm is not None:
+        os.chmod(tmp_filename, perm)  # in case perm includes bits in umask
+
+    f = _wrap_io_open(fd, mode, encoding, errors)
+    af = _AtomicFile(f, tmp_filename, os.path.realpath(filename))
+    return t.cast(t.IO[t.Any], af), True
+
+
+class _AtomicFile:
+    def __init__(self, f: t.IO[t.Any], tmp_filename: str, real_filename: str) -> None:
+        self._f = f
+        self._tmp_filename = tmp_filename
+        self._real_filename = real_filename
+        self.closed = False
+
+    @property
+    def name(self) -> str:
+        return self._real_filename
+
+    def close(self, delete: bool = False) -> None:
+        if self.closed:
+            return
+        self._f.close()
+        os.replace(self._tmp_filename, self._real_filename)
+        self.closed = True
+
+    def __getattr__(self, name: str) -> t.Any:
+        return getattr(self._f, name)
+
+    def __enter__(self) -> "_AtomicFile":
+        return self
+
+    def __exit__(self, exc_type: t.Optional[t.Type[BaseException]], *_: t.Any) -> None:
+        self.close(delete=exc_type is not None)
+
+    def __repr__(self) -> str:
+        return repr(self._f)
+
+
+def strip_ansi(value: str) -> str:
+    return _ansi_re.sub("", value)
+
+
+def _is_jupyter_kernel_output(stream: t.IO[t.Any]) -> bool:
+    while isinstance(stream, (_FixupStream, _NonClosingTextIOWrapper)):
+        stream = stream._stream
+
+    return stream.__class__.__module__.startswith("ipykernel.")
+
+
+def should_strip_ansi(
+    stream: t.Optional[t.IO[t.Any]] = None, color: t.Optional[bool] = None
+) -> bool:
+    if color is None:
+        if stream is None:
+            stream = sys.stdin
+        return not isatty(stream) and not _is_jupyter_kernel_output(stream)
+    return not color
+
+
+# On Windows, wrap the output streams with colorama to support ANSI
+# color codes.
+# NOTE: double check is needed so mypy does not analyze this on Linux
+if sys.platform.startswith("win") and WIN:
+    from ._winconsole import _get_windows_console_stream
+
+    def _get_argv_encoding() -> str:
+        import locale
+
+        return locale.getpreferredencoding()
+
+    _ansi_stream_wrappers: t.MutableMapping[t.TextIO, t.TextIO] = WeakKeyDictionary()
+
+    def auto_wrap_for_ansi(
+        stream: t.TextIO, color: t.Optional[bool] = None
+    ) -> t.TextIO:
+        """Support ANSI color and style codes on Windows by wrapping a
+        stream with colorama.
+        """
+        try:
+            cached = _ansi_stream_wrappers.get(stream)
+        except Exception:
+            cached = None
+
+        if cached is not None:
+            return cached
+
+        import colorama
+
+        strip = should_strip_ansi(stream, color)
+        ansi_wrapper = colorama.AnsiToWin32(stream, strip=strip)
+        rv = t.cast(t.TextIO, ansi_wrapper.stream)
+        _write = rv.write
+
+        def _safe_write(s):
+            try:
+                return _write(s)
+            except BaseException:
+                ansi_wrapper.reset_all()
+                raise
+
+        rv.write = _safe_write
+
+        try:
+            _ansi_stream_wrappers[stream] = rv
+        except Exception:
+            pass
+
+        return rv
+
+else:
+
+    def _get_argv_encoding() -> str:
+        return getattr(sys.stdin, "encoding", None) or sys.getfilesystemencoding()
+
+    def _get_windows_console_stream(
+        f: t.TextIO, encoding: t.Optional[str], errors: t.Optional[str]
+    ) -> t.Optional[t.TextIO]:
+        return None
+
+
+def term_len(x: str) -> int:
+    return len(strip_ansi(x))
+
+
+def isatty(stream: t.IO[t.Any]) -> bool:
+    try:
+        return stream.isatty()
+    except Exception:
+        return False
+
+
+def _make_cached_stream_func(
+    src_func: t.Callable[[], t.Optional[t.TextIO]],
+    wrapper_func: t.Callable[[], t.TextIO],
+) -> t.Callable[[], t.Optional[t.TextIO]]:
+    cache: t.MutableMapping[t.TextIO, t.TextIO] = WeakKeyDictionary()
+
+    def func() -> t.Optional[t.TextIO]:
+        stream = src_func()
+
+        if stream is None:
+            return None
+
+        try:
+            rv = cache.get(stream)
+        except Exception:
+            rv = None
+        if rv is not None:
+            return rv
+        rv = wrapper_func()
+        try:
+            cache[stream] = rv
+        except Exception:
+            pass
+        return rv
+
+    return func
+
+
+_default_text_stdin = _make_cached_stream_func(lambda: sys.stdin, get_text_stdin)
+_default_text_stdout = _make_cached_stream_func(lambda: sys.stdout, get_text_stdout)
+_default_text_stderr = _make_cached_stream_func(lambda: sys.stderr, get_text_stderr)
+
+
+binary_streams: t.Mapping[str, t.Callable[[], t.BinaryIO]] = {
+    "stdin": get_binary_stdin,
+    "stdout": get_binary_stdout,
+    "stderr": get_binary_stderr,
+}
+
+text_streams: t.Mapping[
+    str, t.Callable[[t.Optional[str], t.Optional[str]], t.TextIO]
+] = {
+    "stdin": get_text_stdin,
+    "stdout": get_text_stdout,
+    "stderr": get_text_stderr,
+}

+ 788 - 0
venv/lib/python3.9/site-packages/click/_termui_impl.py

@@ -0,0 +1,788 @@
+"""
+This module contains implementations for the termui module. To keep the
+import time of Click down, some infrequently used functionality is
+placed in this module and only imported as needed.
+"""
+
+import contextlib
+import math
+import os
+import sys
+import time
+import typing as t
+from gettext import gettext as _
+from io import StringIO
+from shutil import which
+from types import TracebackType
+
+from ._compat import _default_text_stdout
+from ._compat import CYGWIN
+from ._compat import get_best_encoding
+from ._compat import isatty
+from ._compat import open_stream
+from ._compat import strip_ansi
+from ._compat import term_len
+from ._compat import WIN
+from .exceptions import ClickException
+from .utils import echo
+
+V = t.TypeVar("V")
+
+if os.name == "nt":
+    BEFORE_BAR = "\r"
+    AFTER_BAR = "\n"
+else:
+    BEFORE_BAR = "\r\033[?25l"
+    AFTER_BAR = "\033[?25h\n"
+
+
+class ProgressBar(t.Generic[V]):
+    def __init__(
+        self,
+        iterable: t.Optional[t.Iterable[V]],
+        length: t.Optional[int] = None,
+        fill_char: str = "#",
+        empty_char: str = " ",
+        bar_template: str = "%(bar)s",
+        info_sep: str = "  ",
+        show_eta: bool = True,
+        show_percent: t.Optional[bool] = None,
+        show_pos: bool = False,
+        item_show_func: t.Optional[t.Callable[[t.Optional[V]], t.Optional[str]]] = None,
+        label: t.Optional[str] = None,
+        file: t.Optional[t.TextIO] = None,
+        color: t.Optional[bool] = None,
+        update_min_steps: int = 1,
+        width: int = 30,
+    ) -> None:
+        self.fill_char = fill_char
+        self.empty_char = empty_char
+        self.bar_template = bar_template
+        self.info_sep = info_sep
+        self.show_eta = show_eta
+        self.show_percent = show_percent
+        self.show_pos = show_pos
+        self.item_show_func = item_show_func
+        self.label: str = label or ""
+
+        if file is None:
+            file = _default_text_stdout()
+
+            # There are no standard streams attached to write to. For example,
+            # pythonw on Windows.
+            if file is None:
+                file = StringIO()
+
+        self.file = file
+        self.color = color
+        self.update_min_steps = update_min_steps
+        self._completed_intervals = 0
+        self.width: int = width
+        self.autowidth: bool = width == 0
+
+        if length is None:
+            from operator import length_hint
+
+            length = length_hint(iterable, -1)
+
+            if length == -1:
+                length = None
+        if iterable is None:
+            if length is None:
+                raise TypeError("iterable or length is required")
+            iterable = t.cast(t.Iterable[V], range(length))
+        self.iter: t.Iterable[V] = iter(iterable)
+        self.length = length
+        self.pos = 0
+        self.avg: t.List[float] = []
+        self.last_eta: float
+        self.start: float
+        self.start = self.last_eta = time.time()
+        self.eta_known: bool = False
+        self.finished: bool = False
+        self.max_width: t.Optional[int] = None
+        self.entered: bool = False
+        self.current_item: t.Optional[V] = None
+        self.is_hidden: bool = not isatty(self.file)
+        self._last_line: t.Optional[str] = None
+
+    def __enter__(self) -> "ProgressBar[V]":
+        self.entered = True
+        self.render_progress()
+        return self
+
+    def __exit__(
+        self,
+        exc_type: t.Optional[t.Type[BaseException]],
+        exc_value: t.Optional[BaseException],
+        tb: t.Optional[TracebackType],
+    ) -> None:
+        self.render_finish()
+
+    def __iter__(self) -> t.Iterator[V]:
+        if not self.entered:
+            raise RuntimeError("You need to use progress bars in a with block.")
+        self.render_progress()
+        return self.generator()
+
+    def __next__(self) -> V:
+        # Iteration is defined in terms of a generator function,
+        # returned by iter(self); use that to define next(). This works
+        # because `self.iter` is an iterable consumed by that generator,
+        # so it is re-entry safe. Calling `next(self.generator())`
+        # twice works and does "what you want".
+        return next(iter(self))
+
+    def render_finish(self) -> None:
+        if self.is_hidden:
+            return
+        self.file.write(AFTER_BAR)
+        self.file.flush()
+
+    @property
+    def pct(self) -> float:
+        if self.finished:
+            return 1.0
+        return min(self.pos / (float(self.length or 1) or 1), 1.0)
+
+    @property
+    def time_per_iteration(self) -> float:
+        if not self.avg:
+            return 0.0
+        return sum(self.avg) / float(len(self.avg))
+
+    @property
+    def eta(self) -> float:
+        if self.length is not None and not self.finished:
+            return self.time_per_iteration * (self.length - self.pos)
+        return 0.0
+
+    def format_eta(self) -> str:
+        if self.eta_known:
+            t = int(self.eta)
+            seconds = t % 60
+            t //= 60
+            minutes = t % 60
+            t //= 60
+            hours = t % 24
+            t //= 24
+            if t > 0:
+                return f"{t}d {hours:02}:{minutes:02}:{seconds:02}"
+            else:
+                return f"{hours:02}:{minutes:02}:{seconds:02}"
+        return ""
+
+    def format_pos(self) -> str:
+        pos = str(self.pos)
+        if self.length is not None:
+            pos += f"/{self.length}"
+        return pos
+
+    def format_pct(self) -> str:
+        return f"{int(self.pct * 100): 4}%"[1:]
+
+    def format_bar(self) -> str:
+        if self.length is not None:
+            bar_length = int(self.pct * self.width)
+            bar = self.fill_char * bar_length
+            bar += self.empty_char * (self.width - bar_length)
+        elif self.finished:
+            bar = self.fill_char * self.width
+        else:
+            chars = list(self.empty_char * (self.width or 1))
+            if self.time_per_iteration != 0:
+                chars[
+                    int(
+                        (math.cos(self.pos * self.time_per_iteration) / 2.0 + 0.5)
+                        * self.width
+                    )
+                ] = self.fill_char
+            bar = "".join(chars)
+        return bar
+
+    def format_progress_line(self) -> str:
+        show_percent = self.show_percent
+
+        info_bits = []
+        if self.length is not None and show_percent is None:
+            show_percent = not self.show_pos
+
+        if self.show_pos:
+            info_bits.append(self.format_pos())
+        if show_percent:
+            info_bits.append(self.format_pct())
+        if self.show_eta and self.eta_known and not self.finished:
+            info_bits.append(self.format_eta())
+        if self.item_show_func is not None:
+            item_info = self.item_show_func(self.current_item)
+            if item_info is not None:
+                info_bits.append(item_info)
+
+        return (
+            self.bar_template
+            % {
+                "label": self.label,
+                "bar": self.format_bar(),
+                "info": self.info_sep.join(info_bits),
+            }
+        ).rstrip()
+
+    def render_progress(self) -> None:
+        import shutil
+
+        if self.is_hidden:
+            # Only output the label as it changes if the output is not a
+            # TTY. Use file=stderr if you expect to be piping stdout.
+            if self._last_line != self.label:
+                self._last_line = self.label
+                echo(self.label, file=self.file, color=self.color)
+
+            return
+
+        buf = []
+        # Update width in case the terminal has been resized
+        if self.autowidth:
+            old_width = self.width
+            self.width = 0
+            clutter_length = term_len(self.format_progress_line())
+            new_width = max(0, shutil.get_terminal_size().columns - clutter_length)
+            if new_width < old_width:
+                buf.append(BEFORE_BAR)
+                buf.append(" " * self.max_width)  # type: ignore
+                self.max_width = new_width
+            self.width = new_width
+
+        clear_width = self.width
+        if self.max_width is not None:
+            clear_width = self.max_width
+
+        buf.append(BEFORE_BAR)
+        line = self.format_progress_line()
+        line_len = term_len(line)
+        if self.max_width is None or self.max_width < line_len:
+            self.max_width = line_len
+
+        buf.append(line)
+        buf.append(" " * (clear_width - line_len))
+        line = "".join(buf)
+        # Render the line only if it changed.
+
+        if line != self._last_line:
+            self._last_line = line
+            echo(line, file=self.file, color=self.color, nl=False)
+            self.file.flush()
+
+    def make_step(self, n_steps: int) -> None:
+        self.pos += n_steps
+        if self.length is not None and self.pos >= self.length:
+            self.finished = True
+
+        if (time.time() - self.last_eta) < 1.0:
+            return
+
+        self.last_eta = time.time()
+
+        # self.avg is a rolling list of length <= 7 of steps where steps are
+        # defined as time elapsed divided by the total progress through
+        # self.length.
+        if self.pos:
+            step = (time.time() - self.start) / self.pos
+        else:
+            step = time.time() - self.start
+
+        self.avg = self.avg[-6:] + [step]
+
+        self.eta_known = self.length is not None
+
+    def update(self, n_steps: int, current_item: t.Optional[V] = None) -> None:
+        """Update the progress bar by advancing a specified number of
+        steps, and optionally set the ``current_item`` for this new
+        position.
+
+        :param n_steps: Number of steps to advance.
+        :param current_item: Optional item to set as ``current_item``
+            for the updated position.
+
+        .. versionchanged:: 8.0
+            Added the ``current_item`` optional parameter.
+
+        .. versionchanged:: 8.0
+            Only render when the number of steps meets the
+            ``update_min_steps`` threshold.
+        """
+        if current_item is not None:
+            self.current_item = current_item
+
+        self._completed_intervals += n_steps
+
+        if self._completed_intervals >= self.update_min_steps:
+            self.make_step(self._completed_intervals)
+            self.render_progress()
+            self._completed_intervals = 0
+
+    def finish(self) -> None:
+        self.eta_known = False
+        self.current_item = None
+        self.finished = True
+
+    def generator(self) -> t.Iterator[V]:
+        """Return a generator which yields the items added to the bar
+        during construction, and updates the progress bar *after* the
+        yielded block returns.
+        """
+        # WARNING: the iterator interface for `ProgressBar` relies on
+        # this and only works because this is a simple generator which
+        # doesn't create or manage additional state. If this function
+        # changes, the impact should be evaluated both against
+        # `iter(bar)` and `next(bar)`. `next()` in particular may call
+        # `self.generator()` repeatedly, and this must remain safe in
+        # order for that interface to work.
+        if not self.entered:
+            raise RuntimeError("You need to use progress bars in a with block.")
+
+        if self.is_hidden:
+            yield from self.iter
+        else:
+            for rv in self.iter:
+                self.current_item = rv
+
+                # This allows show_item_func to be updated before the
+                # item is processed. Only trigger at the beginning of
+                # the update interval.
+                if self._completed_intervals == 0:
+                    self.render_progress()
+
+                yield rv
+                self.update(1)
+
+            self.finish()
+            self.render_progress()
+
+
+def pager(generator: t.Iterable[str], color: t.Optional[bool] = None) -> None:
+    """Decide what method to use for paging through text."""
+    stdout = _default_text_stdout()
+
+    # There are no standard streams attached to write to. For example,
+    # pythonw on Windows.
+    if stdout is None:
+        stdout = StringIO()
+
+    if not isatty(sys.stdin) or not isatty(stdout):
+        return _nullpager(stdout, generator, color)
+    pager_cmd = (os.environ.get("PAGER", None) or "").strip()
+    if pager_cmd:
+        if WIN:
+            if _tempfilepager(generator, pager_cmd, color):
+                return
+        elif _pipepager(generator, pager_cmd, color):
+            return
+    if os.environ.get("TERM") in ("dumb", "emacs"):
+        return _nullpager(stdout, generator, color)
+    if (WIN or sys.platform.startswith("os2")) and _tempfilepager(
+        generator, "more", color
+    ):
+        return
+    if _pipepager(generator, "less", color):
+        return
+
+    import tempfile
+
+    fd, filename = tempfile.mkstemp()
+    os.close(fd)
+    try:
+        if _pipepager(generator, "more", color):
+            return
+        return _nullpager(stdout, generator, color)
+    finally:
+        os.unlink(filename)
+
+
+def _pipepager(generator: t.Iterable[str], cmd: str, color: t.Optional[bool]) -> bool:
+    """Page through text by feeding it to another program.  Invoking a
+    pager through this might support colors.
+
+    Returns True if the command was found, False otherwise and thus another
+    pager should be attempted.
+    """
+    cmd_absolute = which(cmd)
+    if cmd_absolute is None:
+        return False
+
+    import subprocess
+
+    env = dict(os.environ)
+
+    # If we're piping to less we might support colors under the
+    # condition that
+    cmd_detail = cmd.rsplit("/", 1)[-1].split()
+    if color is None and cmd_detail[0] == "less":
+        less_flags = f"{os.environ.get('LESS', '')}{' '.join(cmd_detail[1:])}"
+        if not less_flags:
+            env["LESS"] = "-R"
+            color = True
+        elif "r" in less_flags or "R" in less_flags:
+            color = True
+
+    c = subprocess.Popen(
+        [cmd_absolute],
+        shell=True,
+        stdin=subprocess.PIPE,
+        env=env,
+        errors="replace",
+        text=True,
+    )
+    assert c.stdin is not None
+    try:
+        for text in generator:
+            if not color:
+                text = strip_ansi(text)
+
+            c.stdin.write(text)
+    except (OSError, KeyboardInterrupt):
+        pass
+    else:
+        c.stdin.close()
+
+    # Less doesn't respect ^C, but catches it for its own UI purposes (aborting
+    # search or other commands inside less).
+    #
+    # That means when the user hits ^C, the parent process (click) terminates,
+    # but less is still alive, paging the output and messing up the terminal.
+    #
+    # If the user wants to make the pager exit on ^C, they should set
+    # `LESS='-K'`. It's not our decision to make.
+    while True:
+        try:
+            c.wait()
+        except KeyboardInterrupt:
+            pass
+        else:
+            break
+
+    return True
+
+
+def _tempfilepager(
+    generator: t.Iterable[str],
+    cmd: str,
+    color: t.Optional[bool],
+) -> bool:
+    """Page through text by invoking a program on a temporary file.
+
+    Returns True if the command was found, False otherwise and thus another
+    pager should be attempted.
+    """
+    # Which is necessary for Windows, it is also recommended in the Popen docs.
+    cmd_absolute = which(cmd)
+    if cmd_absolute is None:
+        return False
+
+    import subprocess
+    import tempfile
+
+    fd, filename = tempfile.mkstemp()
+    # TODO: This never terminates if the passed generator never terminates.
+    text = "".join(generator)
+    if not color:
+        text = strip_ansi(text)
+    encoding = get_best_encoding(sys.stdout)
+    with open_stream(filename, "wb")[0] as f:
+        f.write(text.encode(encoding))
+    try:
+        subprocess.call([cmd_absolute, filename])
+    except OSError:
+        # Command not found
+        pass
+    finally:
+        os.close(fd)
+        os.unlink(filename)
+
+    return True
+
+
+def _nullpager(
+    stream: t.TextIO, generator: t.Iterable[str], color: t.Optional[bool]
+) -> None:
+    """Simply print unformatted text.  This is the ultimate fallback."""
+    for text in generator:
+        if not color:
+            text = strip_ansi(text)
+        stream.write(text)
+
+
+class Editor:
+    def __init__(
+        self,
+        editor: t.Optional[str] = None,
+        env: t.Optional[t.Mapping[str, str]] = None,
+        require_save: bool = True,
+        extension: str = ".txt",
+    ) -> None:
+        self.editor = editor
+        self.env = env
+        self.require_save = require_save
+        self.extension = extension
+
+    def get_editor(self) -> str:
+        if self.editor is not None:
+            return self.editor
+        for key in "VISUAL", "EDITOR":
+            rv = os.environ.get(key)
+            if rv:
+                return rv
+        if WIN:
+            return "notepad"
+        for editor in "sensible-editor", "vim", "nano":
+            if which(editor) is not None:
+                return editor
+        return "vi"
+
+    def edit_file(self, filename: str) -> None:
+        import subprocess
+
+        editor = self.get_editor()
+        environ: t.Optional[t.Dict[str, str]] = None
+
+        if self.env:
+            environ = os.environ.copy()
+            environ.update(self.env)
+
+        try:
+            c = subprocess.Popen(f'{editor} "{filename}"', env=environ, shell=True)
+            exit_code = c.wait()
+            if exit_code != 0:
+                raise ClickException(
+                    _("{editor}: Editing failed").format(editor=editor)
+                )
+        except OSError as e:
+            raise ClickException(
+                _("{editor}: Editing failed: {e}").format(editor=editor, e=e)
+            ) from e
+
+    def edit(self, text: t.Optional[t.AnyStr]) -> t.Optional[t.AnyStr]:
+        import tempfile
+
+        if not text:
+            data = b""
+        elif isinstance(text, (bytes, bytearray)):
+            data = text
+        else:
+            if text and not text.endswith("\n"):
+                text += "\n"
+
+            if WIN:
+                data = text.replace("\n", "\r\n").encode("utf-8-sig")
+            else:
+                data = text.encode("utf-8")
+
+        fd, name = tempfile.mkstemp(prefix="editor-", suffix=self.extension)
+        f: t.BinaryIO
+
+        try:
+            with os.fdopen(fd, "wb") as f:
+                f.write(data)
+
+            # If the filesystem resolution is 1 second, like Mac OS
+            # 10.12 Extended, or 2 seconds, like FAT32, and the editor
+            # closes very fast, require_save can fail. Set the modified
+            # time to be 2 seconds in the past to work around this.
+            os.utime(name, (os.path.getatime(name), os.path.getmtime(name) - 2))
+            # Depending on the resolution, the exact value might not be
+            # recorded, so get the new recorded value.
+            timestamp = os.path.getmtime(name)
+
+            self.edit_file(name)
+
+            if self.require_save and os.path.getmtime(name) == timestamp:
+                return None
+
+            with open(name, "rb") as f:
+                rv = f.read()
+
+            if isinstance(text, (bytes, bytearray)):
+                return rv
+
+            return rv.decode("utf-8-sig").replace("\r\n", "\n")  # type: ignore
+        finally:
+            os.unlink(name)
+
+
+def open_url(url: str, wait: bool = False, locate: bool = False) -> int:
+    import subprocess
+
+    def _unquote_file(url: str) -> str:
+        from urllib.parse import unquote
+
+        if url.startswith("file://"):
+            url = unquote(url[7:])
+
+        return url
+
+    if sys.platform == "darwin":
+        args = ["open"]
+        if wait:
+            args.append("-W")
+        if locate:
+            args.append("-R")
+        args.append(_unquote_file(url))
+        null = open("/dev/null", "w")
+        try:
+            return subprocess.Popen(args, stderr=null).wait()
+        finally:
+            null.close()
+    elif WIN:
+        if locate:
+            url = _unquote_file(url)
+            args = ["explorer", f"/select,{url}"]
+        else:
+            args = ["start"]
+            if wait:
+                args.append("/WAIT")
+            args.append("")
+            args.append(url)
+        try:
+            return subprocess.call(args)
+        except OSError:
+            # Command not found
+            return 127
+    elif CYGWIN:
+        if locate:
+            url = _unquote_file(url)
+            args = ["cygstart", os.path.dirname(url)]
+        else:
+            args = ["cygstart"]
+            if wait:
+                args.append("-w")
+            args.append(url)
+        try:
+            return subprocess.call(args)
+        except OSError:
+            # Command not found
+            return 127
+
+    try:
+        if locate:
+            url = os.path.dirname(_unquote_file(url)) or "."
+        else:
+            url = _unquote_file(url)
+        c = subprocess.Popen(["xdg-open", url])
+        if wait:
+            return c.wait()
+        return 0
+    except OSError:
+        if url.startswith(("http://", "https://")) and not locate and not wait:
+            import webbrowser
+
+            webbrowser.open(url)
+            return 0
+        return 1
+
+
+def _translate_ch_to_exc(ch: str) -> t.Optional[BaseException]:
+    if ch == "\x03":
+        raise KeyboardInterrupt()
+
+    if ch == "\x04" and not WIN:  # Unix-like, Ctrl+D
+        raise EOFError()
+
+    if ch == "\x1a" and WIN:  # Windows, Ctrl+Z
+        raise EOFError()
+
+    return None
+
+
+if WIN:
+    import msvcrt
+
+    @contextlib.contextmanager
+    def raw_terminal() -> t.Iterator[int]:
+        yield -1
+
+    def getchar(echo: bool) -> str:
+        # The function `getch` will return a bytes object corresponding to
+        # the pressed character. Since Windows 10 build 1803, it will also
+        # return \x00 when called a second time after pressing a regular key.
+        #
+        # `getwch` does not share this probably-bugged behavior. Moreover, it
+        # returns a Unicode object by default, which is what we want.
+        #
+        # Either of these functions will return \x00 or \xe0 to indicate
+        # a special key, and you need to call the same function again to get
+        # the "rest" of the code. The fun part is that \u00e0 is
+        # "latin small letter a with grave", so if you type that on a French
+        # keyboard, you _also_ get a \xe0.
+        # E.g., consider the Up arrow. This returns \xe0 and then \x48. The
+        # resulting Unicode string reads as "a with grave" + "capital H".
+        # This is indistinguishable from when the user actually types
+        # "a with grave" and then "capital H".
+        #
+        # When \xe0 is returned, we assume it's part of a special-key sequence
+        # and call `getwch` again, but that means that when the user types
+        # the \u00e0 character, `getchar` doesn't return until a second
+        # character is typed.
+        # The alternative is returning immediately, but that would mess up
+        # cross-platform handling of arrow keys and others that start with
+        # \xe0. Another option is using `getch`, but then we can't reliably
+        # read non-ASCII characters, because return values of `getch` are
+        # limited to the current 8-bit codepage.
+        #
+        # Anyway, Click doesn't claim to do this Right(tm), and using `getwch`
+        # is doing the right thing in more situations than with `getch`.
+        func: t.Callable[[], str]
+
+        if echo:
+            func = msvcrt.getwche  # type: ignore
+        else:
+            func = msvcrt.getwch  # type: ignore
+
+        rv = func()
+
+        if rv in ("\x00", "\xe0"):
+            # \x00 and \xe0 are control characters that indicate special key,
+            # see above.
+            rv += func()
+
+        _translate_ch_to_exc(rv)
+        return rv
+
+else:
+    import termios
+    import tty
+
+    @contextlib.contextmanager
+    def raw_terminal() -> t.Iterator[int]:
+        f: t.Optional[t.TextIO]
+        fd: int
+
+        if not isatty(sys.stdin):
+            f = open("/dev/tty")
+            fd = f.fileno()
+        else:
+            fd = sys.stdin.fileno()
+            f = None
+
+        try:
+            old_settings = termios.tcgetattr(fd)
+
+            try:
+                tty.setraw(fd)
+                yield fd
+            finally:
+                termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
+                sys.stdout.flush()
+
+                if f is not None:
+                    f.close()
+        except termios.error:
+            pass
+
+    def getchar(echo: bool) -> str:
+        with raw_terminal() as fd:
+            ch = os.read(fd, 32).decode(get_best_encoding(sys.stdin), "replace")
+
+            if echo and isatty(sys.stdout):
+                sys.stdout.write(ch)
+
+            _translate_ch_to_exc(ch)
+            return ch

+ 49 - 0
venv/lib/python3.9/site-packages/click/_textwrap.py

@@ -0,0 +1,49 @@
+import textwrap
+import typing as t
+from contextlib import contextmanager
+
+
+class TextWrapper(textwrap.TextWrapper):
+    def _handle_long_word(
+        self,
+        reversed_chunks: t.List[str],
+        cur_line: t.List[str],
+        cur_len: int,
+        width: int,
+    ) -> None:
+        space_left = max(width - cur_len, 1)
+
+        if self.break_long_words:
+            last = reversed_chunks[-1]
+            cut = last[:space_left]
+            res = last[space_left:]
+            cur_line.append(cut)
+            reversed_chunks[-1] = res
+        elif not cur_line:
+            cur_line.append(reversed_chunks.pop())
+
+    @contextmanager
+    def extra_indent(self, indent: str) -> t.Iterator[None]:
+        old_initial_indent = self.initial_indent
+        old_subsequent_indent = self.subsequent_indent
+        self.initial_indent += indent
+        self.subsequent_indent += indent
+
+        try:
+            yield
+        finally:
+            self.initial_indent = old_initial_indent
+            self.subsequent_indent = old_subsequent_indent
+
+    def indent_only(self, text: str) -> str:
+        rv = []
+
+        for idx, line in enumerate(text.splitlines()):
+            indent = self.initial_indent
+
+            if idx > 0:
+                indent = self.subsequent_indent
+
+            rv.append(f"{indent}{line}")
+
+        return "\n".join(rv)

+ 279 - 0
venv/lib/python3.9/site-packages/click/_winconsole.py

@@ -0,0 +1,279 @@
+# This module is based on the excellent work by Adam Bartoš who
+# provided a lot of what went into the implementation here in
+# the discussion to issue1602 in the Python bug tracker.
+#
+# There are some general differences in regards to how this works
+# compared to the original patches as we do not need to patch
+# the entire interpreter but just work in our little world of
+# echo and prompt.
+import io
+import sys
+import time
+import typing as t
+from ctypes import byref
+from ctypes import c_char
+from ctypes import c_char_p
+from ctypes import c_int
+from ctypes import c_ssize_t
+from ctypes import c_ulong
+from ctypes import c_void_p
+from ctypes import POINTER
+from ctypes import py_object
+from ctypes import Structure
+from ctypes.wintypes import DWORD
+from ctypes.wintypes import HANDLE
+from ctypes.wintypes import LPCWSTR
+from ctypes.wintypes import LPWSTR
+
+from ._compat import _NonClosingTextIOWrapper
+
+assert sys.platform == "win32"
+import msvcrt  # noqa: E402
+from ctypes import windll  # noqa: E402
+from ctypes import WINFUNCTYPE  # noqa: E402
+
+c_ssize_p = POINTER(c_ssize_t)
+
+kernel32 = windll.kernel32
+GetStdHandle = kernel32.GetStdHandle
+ReadConsoleW = kernel32.ReadConsoleW
+WriteConsoleW = kernel32.WriteConsoleW
+GetConsoleMode = kernel32.GetConsoleMode
+GetLastError = kernel32.GetLastError
+GetCommandLineW = WINFUNCTYPE(LPWSTR)(("GetCommandLineW", windll.kernel32))
+CommandLineToArgvW = WINFUNCTYPE(POINTER(LPWSTR), LPCWSTR, POINTER(c_int))(
+    ("CommandLineToArgvW", windll.shell32)
+)
+LocalFree = WINFUNCTYPE(c_void_p, c_void_p)(("LocalFree", windll.kernel32))
+
+STDIN_HANDLE = GetStdHandle(-10)
+STDOUT_HANDLE = GetStdHandle(-11)
+STDERR_HANDLE = GetStdHandle(-12)
+
+PyBUF_SIMPLE = 0
+PyBUF_WRITABLE = 1
+
+ERROR_SUCCESS = 0
+ERROR_NOT_ENOUGH_MEMORY = 8
+ERROR_OPERATION_ABORTED = 995
+
+STDIN_FILENO = 0
+STDOUT_FILENO = 1
+STDERR_FILENO = 2
+
+EOF = b"\x1a"
+MAX_BYTES_WRITTEN = 32767
+
+try:
+    from ctypes import pythonapi
+except ImportError:
+    # On PyPy we cannot get buffers so our ability to operate here is
+    # severely limited.
+    get_buffer = None
+else:
+
+    class Py_buffer(Structure):
+        _fields_ = [
+            ("buf", c_void_p),
+            ("obj", py_object),
+            ("len", c_ssize_t),
+            ("itemsize", c_ssize_t),
+            ("readonly", c_int),
+            ("ndim", c_int),
+            ("format", c_char_p),
+            ("shape", c_ssize_p),
+            ("strides", c_ssize_p),
+            ("suboffsets", c_ssize_p),
+            ("internal", c_void_p),
+        ]
+
+    PyObject_GetBuffer = pythonapi.PyObject_GetBuffer
+    PyBuffer_Release = pythonapi.PyBuffer_Release
+
+    def get_buffer(obj, writable=False):
+        buf = Py_buffer()
+        flags = PyBUF_WRITABLE if writable else PyBUF_SIMPLE
+        PyObject_GetBuffer(py_object(obj), byref(buf), flags)
+
+        try:
+            buffer_type = c_char * buf.len
+            return buffer_type.from_address(buf.buf)
+        finally:
+            PyBuffer_Release(byref(buf))
+
+
+class _WindowsConsoleRawIOBase(io.RawIOBase):
+    def __init__(self, handle):
+        self.handle = handle
+
+    def isatty(self):
+        super().isatty()
+        return True
+
+
+class _WindowsConsoleReader(_WindowsConsoleRawIOBase):
+    def readable(self):
+        return True
+
+    def readinto(self, b):
+        bytes_to_be_read = len(b)
+        if not bytes_to_be_read:
+            return 0
+        elif bytes_to_be_read % 2:
+            raise ValueError(
+                "cannot read odd number of bytes from UTF-16-LE encoded console"
+            )
+
+        buffer = get_buffer(b, writable=True)
+        code_units_to_be_read = bytes_to_be_read // 2
+        code_units_read = c_ulong()
+
+        rv = ReadConsoleW(
+            HANDLE(self.handle),
+            buffer,
+            code_units_to_be_read,
+            byref(code_units_read),
+            None,
+        )
+        if GetLastError() == ERROR_OPERATION_ABORTED:
+            # wait for KeyboardInterrupt
+            time.sleep(0.1)
+        if not rv:
+            raise OSError(f"Windows error: {GetLastError()}")
+
+        if buffer[0] == EOF:
+            return 0
+        return 2 * code_units_read.value
+
+
+class _WindowsConsoleWriter(_WindowsConsoleRawIOBase):
+    def writable(self):
+        return True
+
+    @staticmethod
+    def _get_error_message(errno):
+        if errno == ERROR_SUCCESS:
+            return "ERROR_SUCCESS"
+        elif errno == ERROR_NOT_ENOUGH_MEMORY:
+            return "ERROR_NOT_ENOUGH_MEMORY"
+        return f"Windows error {errno}"
+
+    def write(self, b):
+        bytes_to_be_written = len(b)
+        buf = get_buffer(b)
+        code_units_to_be_written = min(bytes_to_be_written, MAX_BYTES_WRITTEN) // 2
+        code_units_written = c_ulong()
+
+        WriteConsoleW(
+            HANDLE(self.handle),
+            buf,
+            code_units_to_be_written,
+            byref(code_units_written),
+            None,
+        )
+        bytes_written = 2 * code_units_written.value
+
+        if bytes_written == 0 and bytes_to_be_written > 0:
+            raise OSError(self._get_error_message(GetLastError()))
+        return bytes_written
+
+
+class ConsoleStream:
+    def __init__(self, text_stream: t.TextIO, byte_stream: t.BinaryIO) -> None:
+        self._text_stream = text_stream
+        self.buffer = byte_stream
+
+    @property
+    def name(self) -> str:
+        return self.buffer.name
+
+    def write(self, x: t.AnyStr) -> int:
+        if isinstance(x, str):
+            return self._text_stream.write(x)
+        try:
+            self.flush()
+        except Exception:
+            pass
+        return self.buffer.write(x)
+
+    def writelines(self, lines: t.Iterable[t.AnyStr]) -> None:
+        for line in lines:
+            self.write(line)
+
+    def __getattr__(self, name: str) -> t.Any:
+        return getattr(self._text_stream, name)
+
+    def isatty(self) -> bool:
+        return self.buffer.isatty()
+
+    def __repr__(self):
+        return f"<ConsoleStream name={self.name!r} encoding={self.encoding!r}>"
+
+
+def _get_text_stdin(buffer_stream: t.BinaryIO) -> t.TextIO:
+    text_stream = _NonClosingTextIOWrapper(
+        io.BufferedReader(_WindowsConsoleReader(STDIN_HANDLE)),
+        "utf-16-le",
+        "strict",
+        line_buffering=True,
+    )
+    return t.cast(t.TextIO, ConsoleStream(text_stream, buffer_stream))
+
+
+def _get_text_stdout(buffer_stream: t.BinaryIO) -> t.TextIO:
+    text_stream = _NonClosingTextIOWrapper(
+        io.BufferedWriter(_WindowsConsoleWriter(STDOUT_HANDLE)),
+        "utf-16-le",
+        "strict",
+        line_buffering=True,
+    )
+    return t.cast(t.TextIO, ConsoleStream(text_stream, buffer_stream))
+
+
+def _get_text_stderr(buffer_stream: t.BinaryIO) -> t.TextIO:
+    text_stream = _NonClosingTextIOWrapper(
+        io.BufferedWriter(_WindowsConsoleWriter(STDERR_HANDLE)),
+        "utf-16-le",
+        "strict",
+        line_buffering=True,
+    )
+    return t.cast(t.TextIO, ConsoleStream(text_stream, buffer_stream))
+
+
+_stream_factories: t.Mapping[int, t.Callable[[t.BinaryIO], t.TextIO]] = {
+    0: _get_text_stdin,
+    1: _get_text_stdout,
+    2: _get_text_stderr,
+}
+
+
+def _is_console(f: t.TextIO) -> bool:
+    if not hasattr(f, "fileno"):
+        return False
+
+    try:
+        fileno = f.fileno()
+    except (OSError, io.UnsupportedOperation):
+        return False
+
+    handle = msvcrt.get_osfhandle(fileno)
+    return bool(GetConsoleMode(handle, byref(DWORD())))
+
+
+def _get_windows_console_stream(
+    f: t.TextIO, encoding: t.Optional[str], errors: t.Optional[str]
+) -> t.Optional[t.TextIO]:
+    if (
+        get_buffer is not None
+        and encoding in {"utf-16-le", None}
+        and errors in {"strict", None}
+        and _is_console(f)
+    ):
+        func = _stream_factories.get(f.fileno())
+        if func is not None:
+            b = getattr(f, "buffer", None)
+
+            if b is None:
+                return None
+
+            return func(b)

+ 3047 - 0
venv/lib/python3.9/site-packages/click/core.py

@@ -0,0 +1,3047 @@
+import enum
+import errno
+import inspect
+import os
+import sys
+import typing as t
+from collections import abc
+from contextlib import contextmanager
+from contextlib import ExitStack
+from functools import update_wrapper
+from gettext import gettext as _
+from gettext import ngettext
+from itertools import repeat
+from types import TracebackType
+
+from . import types
+from .exceptions import Abort
+from .exceptions import BadParameter
+from .exceptions import ClickException
+from .exceptions import Exit
+from .exceptions import MissingParameter
+from .exceptions import UsageError
+from .formatting import HelpFormatter
+from .formatting import join_options
+from .globals import pop_context
+from .globals import push_context
+from .parser import _flag_needs_value
+from .parser import OptionParser
+from .parser import split_opt
+from .termui import confirm
+from .termui import prompt
+from .termui import style
+from .utils import _detect_program_name
+from .utils import _expand_args
+from .utils import echo
+from .utils import make_default_short_help
+from .utils import make_str
+from .utils import PacifyFlushWrapper
+
+if t.TYPE_CHECKING:
+    import typing_extensions as te
+
+    from .decorators import HelpOption
+    from .shell_completion import CompletionItem
+
+F = t.TypeVar("F", bound=t.Callable[..., t.Any])
+V = t.TypeVar("V")
+
+
+def _complete_visible_commands(
+    ctx: "Context", incomplete: str
+) -> t.Iterator[t.Tuple[str, "Command"]]:
+    """List all the subcommands of a group that start with the
+    incomplete value and aren't hidden.
+
+    :param ctx: Invocation context for the group.
+    :param incomplete: Value being completed. May be empty.
+    """
+    multi = t.cast(MultiCommand, ctx.command)
+
+    for name in multi.list_commands(ctx):
+        if name.startswith(incomplete):
+            command = multi.get_command(ctx, name)
+
+            if command is not None and not command.hidden:
+                yield name, command
+
+
+def _check_multicommand(
+    base_command: "MultiCommand", cmd_name: str, cmd: "Command", register: bool = False
+) -> None:
+    if not base_command.chain or not isinstance(cmd, MultiCommand):
+        return
+    if register:
+        hint = (
+            "It is not possible to add multi commands as children to"
+            " another multi command that is in chain mode."
+        )
+    else:
+        hint = (
+            "Found a multi command as subcommand to a multi command"
+            " that is in chain mode. This is not supported."
+        )
+    raise RuntimeError(
+        f"{hint}. Command {base_command.name!r} is set to chain and"
+        f" {cmd_name!r} was added as a subcommand but it in itself is a"
+        f" multi command. ({cmd_name!r} is a {type(cmd).__name__}"
+        f" within a chained {type(base_command).__name__} named"
+        f" {base_command.name!r})."
+    )
+
+
+def batch(iterable: t.Iterable[V], batch_size: int) -> t.List[t.Tuple[V, ...]]:
+    return list(zip(*repeat(iter(iterable), batch_size)))
+
+
+@contextmanager
+def augment_usage_errors(
+    ctx: "Context", param: t.Optional["Parameter"] = None
+) -> t.Iterator[None]:
+    """Context manager that attaches extra information to exceptions."""
+    try:
+        yield
+    except BadParameter as e:
+        if e.ctx is None:
+            e.ctx = ctx
+        if param is not None and e.param is None:
+            e.param = param
+        raise
+    except UsageError as e:
+        if e.ctx is None:
+            e.ctx = ctx
+        raise
+
+
+def iter_params_for_processing(
+    invocation_order: t.Sequence["Parameter"],
+    declaration_order: t.Sequence["Parameter"],
+) -> t.List["Parameter"]:
+    """Returns all declared parameters in the order they should be processed.
+
+    The declared parameters are re-shuffled depending on the order in which
+    they were invoked, as well as the eagerness of each parameters.
+
+    The invocation order takes precedence over the declaration order. I.e. the
+    order in which the user provided them to the CLI is respected.
+
+    This behavior and its effect on callback evaluation is detailed at:
+    https://click.palletsprojects.com/en/stable/advanced/#callback-evaluation-order
+    """
+
+    def sort_key(item: "Parameter") -> t.Tuple[bool, float]:
+        try:
+            idx: float = invocation_order.index(item)
+        except ValueError:
+            idx = float("inf")
+
+        return not item.is_eager, idx
+
+    return sorted(declaration_order, key=sort_key)
+
+
+class ParameterSource(enum.Enum):
+    """This is an :class:`~enum.Enum` that indicates the source of a
+    parameter's value.
+
+    Use :meth:`click.Context.get_parameter_source` to get the
+    source for a parameter by name.
+
+    .. versionchanged:: 8.0
+        Use :class:`~enum.Enum` and drop the ``validate`` method.
+
+    .. versionchanged:: 8.0
+        Added the ``PROMPT`` value.
+    """
+
+    COMMANDLINE = enum.auto()
+    """The value was provided by the command line args."""
+    ENVIRONMENT = enum.auto()
+    """The value was provided with an environment variable."""
+    DEFAULT = enum.auto()
+    """Used the default specified by the parameter."""
+    DEFAULT_MAP = enum.auto()
+    """Used a default provided by :attr:`Context.default_map`."""
+    PROMPT = enum.auto()
+    """Used a prompt to confirm a default or provide a value."""
+
+
+class Context:
+    """The context is a special internal object that holds state relevant
+    for the script execution at every single level.  It's normally invisible
+    to commands unless they opt-in to getting access to it.
+
+    The context is useful as it can pass internal objects around and can
+    control special execution features such as reading data from
+    environment variables.
+
+    A context can be used as context manager in which case it will call
+    :meth:`close` on teardown.
+
+    :param command: the command class for this context.
+    :param parent: the parent context.
+    :param info_name: the info name for this invocation.  Generally this
+                      is the most descriptive name for the script or
+                      command.  For the toplevel script it is usually
+                      the name of the script, for commands below it it's
+                      the name of the script.
+    :param obj: an arbitrary object of user data.
+    :param auto_envvar_prefix: the prefix to use for automatic environment
+                               variables.  If this is `None` then reading
+                               from environment variables is disabled.  This
+                               does not affect manually set environment
+                               variables which are always read.
+    :param default_map: a dictionary (like object) with default values
+                        for parameters.
+    :param terminal_width: the width of the terminal.  The default is
+                           inherit from parent context.  If no context
+                           defines the terminal width then auto
+                           detection will be applied.
+    :param max_content_width: the maximum width for content rendered by
+                              Click (this currently only affects help
+                              pages).  This defaults to 80 characters if
+                              not overridden.  In other words: even if the
+                              terminal is larger than that, Click will not
+                              format things wider than 80 characters by
+                              default.  In addition to that, formatters might
+                              add some safety mapping on the right.
+    :param resilient_parsing: if this flag is enabled then Click will
+                              parse without any interactivity or callback
+                              invocation.  Default values will also be
+                              ignored.  This is useful for implementing
+                              things such as completion support.
+    :param allow_extra_args: if this is set to `True` then extra arguments
+                             at the end will not raise an error and will be
+                             kept on the context.  The default is to inherit
+                             from the command.
+    :param allow_interspersed_args: if this is set to `False` then options
+                                    and arguments cannot be mixed.  The
+                                    default is to inherit from the command.
+    :param ignore_unknown_options: instructs click to ignore options it does
+                                   not know and keeps them for later
+                                   processing.
+    :param help_option_names: optionally a list of strings that define how
+                              the default help parameter is named.  The
+                              default is ``['--help']``.
+    :param token_normalize_func: an optional function that is used to
+                                 normalize tokens (options, choices,
+                                 etc.).  This for instance can be used to
+                                 implement case insensitive behavior.
+    :param color: controls if the terminal supports ANSI colors or not.  The
+                  default is autodetection.  This is only needed if ANSI
+                  codes are used in texts that Click prints which is by
+                  default not the case.  This for instance would affect
+                  help output.
+    :param show_default: Show the default value for commands. If this
+        value is not set, it defaults to the value from the parent
+        context. ``Command.show_default`` overrides this default for the
+        specific command.
+
+    .. versionchanged:: 8.1
+        The ``show_default`` parameter is overridden by
+        ``Command.show_default``, instead of the other way around.
+
+    .. versionchanged:: 8.0
+        The ``show_default`` parameter defaults to the value from the
+        parent context.
+
+    .. versionchanged:: 7.1
+       Added the ``show_default`` parameter.
+
+    .. versionchanged:: 4.0
+        Added the ``color``, ``ignore_unknown_options``, and
+        ``max_content_width`` parameters.
+
+    .. versionchanged:: 3.0
+        Added the ``allow_extra_args`` and ``allow_interspersed_args``
+        parameters.
+
+    .. versionchanged:: 2.0
+        Added the ``resilient_parsing``, ``help_option_names``, and
+        ``token_normalize_func`` parameters.
+    """
+
+    #: The formatter class to create with :meth:`make_formatter`.
+    #:
+    #: .. versionadded:: 8.0
+    formatter_class: t.Type["HelpFormatter"] = HelpFormatter
+
+    def __init__(
+        self,
+        command: "Command",
+        parent: t.Optional["Context"] = None,
+        info_name: t.Optional[str] = None,
+        obj: t.Optional[t.Any] = None,
+        auto_envvar_prefix: t.Optional[str] = None,
+        default_map: t.Optional[t.MutableMapping[str, t.Any]] = None,
+        terminal_width: t.Optional[int] = None,
+        max_content_width: t.Optional[int] = None,
+        resilient_parsing: bool = False,
+        allow_extra_args: t.Optional[bool] = None,
+        allow_interspersed_args: t.Optional[bool] = None,
+        ignore_unknown_options: t.Optional[bool] = None,
+        help_option_names: t.Optional[t.List[str]] = None,
+        token_normalize_func: t.Optional[t.Callable[[str], str]] = None,
+        color: t.Optional[bool] = None,
+        show_default: t.Optional[bool] = None,
+    ) -> None:
+        #: the parent context or `None` if none exists.
+        self.parent = parent
+        #: the :class:`Command` for this context.
+        self.command = command
+        #: the descriptive information name
+        self.info_name = info_name
+        #: Map of parameter names to their parsed values. Parameters
+        #: with ``expose_value=False`` are not stored.
+        self.params: t.Dict[str, t.Any] = {}
+        #: the leftover arguments.
+        self.args: t.List[str] = []
+        #: protected arguments.  These are arguments that are prepended
+        #: to `args` when certain parsing scenarios are encountered but
+        #: must be never propagated to another arguments.  This is used
+        #: to implement nested parsing.
+        self.protected_args: t.List[str] = []
+        #: the collected prefixes of the command's options.
+        self._opt_prefixes: t.Set[str] = set(parent._opt_prefixes) if parent else set()
+
+        if obj is None and parent is not None:
+            obj = parent.obj
+
+        #: the user object stored.
+        self.obj: t.Any = obj
+        self._meta: t.Dict[str, t.Any] = getattr(parent, "meta", {})
+
+        #: A dictionary (-like object) with defaults for parameters.
+        if (
+            default_map is None
+            and info_name is not None
+            and parent is not None
+            and parent.default_map is not None
+        ):
+            default_map = parent.default_map.get(info_name)
+
+        self.default_map: t.Optional[t.MutableMapping[str, t.Any]] = default_map
+
+        #: This flag indicates if a subcommand is going to be executed. A
+        #: group callback can use this information to figure out if it's
+        #: being executed directly or because the execution flow passes
+        #: onwards to a subcommand. By default it's None, but it can be
+        #: the name of the subcommand to execute.
+        #:
+        #: If chaining is enabled this will be set to ``'*'`` in case
+        #: any commands are executed.  It is however not possible to
+        #: figure out which ones.  If you require this knowledge you
+        #: should use a :func:`result_callback`.
+        self.invoked_subcommand: t.Optional[str] = None
+
+        if terminal_width is None and parent is not None:
+            terminal_width = parent.terminal_width
+
+        #: The width of the terminal (None is autodetection).
+        self.terminal_width: t.Optional[int] = terminal_width
+
+        if max_content_width is None and parent is not None:
+            max_content_width = parent.max_content_width
+
+        #: The maximum width of formatted content (None implies a sensible
+        #: default which is 80 for most things).
+        self.max_content_width: t.Optional[int] = max_content_width
+
+        if allow_extra_args is None:
+            allow_extra_args = command.allow_extra_args
+
+        #: Indicates if the context allows extra args or if it should
+        #: fail on parsing.
+        #:
+        #: .. versionadded:: 3.0
+        self.allow_extra_args = allow_extra_args
+
+        if allow_interspersed_args is None:
+            allow_interspersed_args = command.allow_interspersed_args
+
+        #: Indicates if the context allows mixing of arguments and
+        #: options or not.
+        #:
+        #: .. versionadded:: 3.0
+        self.allow_interspersed_args: bool = allow_interspersed_args
+
+        if ignore_unknown_options is None:
+            ignore_unknown_options = command.ignore_unknown_options
+
+        #: Instructs click to ignore options that a command does not
+        #: understand and will store it on the context for later
+        #: processing.  This is primarily useful for situations where you
+        #: want to call into external programs.  Generally this pattern is
+        #: strongly discouraged because it's not possibly to losslessly
+        #: forward all arguments.
+        #:
+        #: .. versionadded:: 4.0
+        self.ignore_unknown_options: bool = ignore_unknown_options
+
+        if help_option_names is None:
+            if parent is not None:
+                help_option_names = parent.help_option_names
+            else:
+                help_option_names = ["--help"]
+
+        #: The names for the help options.
+        self.help_option_names: t.List[str] = help_option_names
+
+        if token_normalize_func is None and parent is not None:
+            token_normalize_func = parent.token_normalize_func
+
+        #: An optional normalization function for tokens.  This is
+        #: options, choices, commands etc.
+        self.token_normalize_func: t.Optional[t.Callable[[str], str]] = (
+            token_normalize_func
+        )
+
+        #: Indicates if resilient parsing is enabled.  In that case Click
+        #: will do its best to not cause any failures and default values
+        #: will be ignored. Useful for completion.
+        self.resilient_parsing: bool = resilient_parsing
+
+        # If there is no envvar prefix yet, but the parent has one and
+        # the command on this level has a name, we can expand the envvar
+        # prefix automatically.
+        if auto_envvar_prefix is None:
+            if (
+                parent is not None
+                and parent.auto_envvar_prefix is not None
+                and self.info_name is not None
+            ):
+                auto_envvar_prefix = (
+                    f"{parent.auto_envvar_prefix}_{self.info_name.upper()}"
+                )
+        else:
+            auto_envvar_prefix = auto_envvar_prefix.upper()
+
+        if auto_envvar_prefix is not None:
+            auto_envvar_prefix = auto_envvar_prefix.replace("-", "_")
+
+        self.auto_envvar_prefix: t.Optional[str] = auto_envvar_prefix
+
+        if color is None and parent is not None:
+            color = parent.color
+
+        #: Controls if styling output is wanted or not.
+        self.color: t.Optional[bool] = color
+
+        if show_default is None and parent is not None:
+            show_default = parent.show_default
+
+        #: Show option default values when formatting help text.
+        self.show_default: t.Optional[bool] = show_default
+
+        self._close_callbacks: t.List[t.Callable[[], t.Any]] = []
+        self._depth = 0
+        self._parameter_source: t.Dict[str, ParameterSource] = {}
+        self._exit_stack = ExitStack()
+
+    def to_info_dict(self) -> t.Dict[str, t.Any]:
+        """Gather information that could be useful for a tool generating
+        user-facing documentation. This traverses the entire CLI
+        structure.
+
+        .. code-block:: python
+
+            with Context(cli) as ctx:
+                info = ctx.to_info_dict()
+
+        .. versionadded:: 8.0
+        """
+        return {
+            "command": self.command.to_info_dict(self),
+            "info_name": self.info_name,
+            "allow_extra_args": self.allow_extra_args,
+            "allow_interspersed_args": self.allow_interspersed_args,
+            "ignore_unknown_options": self.ignore_unknown_options,
+            "auto_envvar_prefix": self.auto_envvar_prefix,
+        }
+
+    def __enter__(self) -> "Context":
+        self._depth += 1
+        push_context(self)
+        return self
+
+    def __exit__(
+        self,
+        exc_type: t.Optional[t.Type[BaseException]],
+        exc_value: t.Optional[BaseException],
+        tb: t.Optional[TracebackType],
+    ) -> None:
+        self._depth -= 1
+        if self._depth == 0:
+            self.close()
+        pop_context()
+
+    @contextmanager
+    def scope(self, cleanup: bool = True) -> t.Iterator["Context"]:
+        """This helper method can be used with the context object to promote
+        it to the current thread local (see :func:`get_current_context`).
+        The default behavior of this is to invoke the cleanup functions which
+        can be disabled by setting `cleanup` to `False`.  The cleanup
+        functions are typically used for things such as closing file handles.
+
+        If the cleanup is intended the context object can also be directly
+        used as a context manager.
+
+        Example usage::
+
+            with ctx.scope():
+                assert get_current_context() is ctx
+
+        This is equivalent::
+
+            with ctx:
+                assert get_current_context() is ctx
+
+        .. versionadded:: 5.0
+
+        :param cleanup: controls if the cleanup functions should be run or
+                        not.  The default is to run these functions.  In
+                        some situations the context only wants to be
+                        temporarily pushed in which case this can be disabled.
+                        Nested pushes automatically defer the cleanup.
+        """
+        if not cleanup:
+            self._depth += 1
+        try:
+            with self as rv:
+                yield rv
+        finally:
+            if not cleanup:
+                self._depth -= 1
+
+    @property
+    def meta(self) -> t.Dict[str, t.Any]:
+        """This is a dictionary which is shared with all the contexts
+        that are nested.  It exists so that click utilities can store some
+        state here if they need to.  It is however the responsibility of
+        that code to manage this dictionary well.
+
+        The keys are supposed to be unique dotted strings.  For instance
+        module paths are a good choice for it.  What is stored in there is
+        irrelevant for the operation of click.  However what is important is
+        that code that places data here adheres to the general semantics of
+        the system.
+
+        Example usage::
+
+            LANG_KEY = f'{__name__}.lang'
+
+            def set_language(value):
+                ctx = get_current_context()
+                ctx.meta[LANG_KEY] = value
+
+            def get_language():
+                return get_current_context().meta.get(LANG_KEY, 'en_US')
+
+        .. versionadded:: 5.0
+        """
+        return self._meta
+
+    def make_formatter(self) -> HelpFormatter:
+        """Creates the :class:`~click.HelpFormatter` for the help and
+        usage output.
+
+        To quickly customize the formatter class used without overriding
+        this method, set the :attr:`formatter_class` attribute.
+
+        .. versionchanged:: 8.0
+            Added the :attr:`formatter_class` attribute.
+        """
+        return self.formatter_class(
+            width=self.terminal_width, max_width=self.max_content_width
+        )
+
+    def with_resource(self, context_manager: t.ContextManager[V]) -> V:
+        """Register a resource as if it were used in a ``with``
+        statement. The resource will be cleaned up when the context is
+        popped.
+
+        Uses :meth:`contextlib.ExitStack.enter_context`. It calls the
+        resource's ``__enter__()`` method and returns the result. When
+        the context is popped, it closes the stack, which calls the
+        resource's ``__exit__()`` method.
+
+        To register a cleanup function for something that isn't a
+        context manager, use :meth:`call_on_close`. Or use something
+        from :mod:`contextlib` to turn it into a context manager first.
+
+        .. code-block:: python
+
+            @click.group()
+            @click.option("--name")
+            @click.pass_context
+            def cli(ctx):
+                ctx.obj = ctx.with_resource(connect_db(name))
+
+        :param context_manager: The context manager to enter.
+        :return: Whatever ``context_manager.__enter__()`` returns.
+
+        .. versionadded:: 8.0
+        """
+        return self._exit_stack.enter_context(context_manager)
+
+    def call_on_close(self, f: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]:
+        """Register a function to be called when the context tears down.
+
+        This can be used to close resources opened during the script
+        execution. Resources that support Python's context manager
+        protocol which would be used in a ``with`` statement should be
+        registered with :meth:`with_resource` instead.
+
+        :param f: The function to execute on teardown.
+        """
+        return self._exit_stack.callback(f)
+
+    def close(self) -> None:
+        """Invoke all close callbacks registered with
+        :meth:`call_on_close`, and exit all context managers entered
+        with :meth:`with_resource`.
+        """
+        self._exit_stack.close()
+        # In case the context is reused, create a new exit stack.
+        self._exit_stack = ExitStack()
+
+    @property
+    def command_path(self) -> str:
+        """The computed command path.  This is used for the ``usage``
+        information on the help page.  It's automatically created by
+        combining the info names of the chain of contexts to the root.
+        """
+        rv = ""
+        if self.info_name is not None:
+            rv = self.info_name
+        if self.parent is not None:
+            parent_command_path = [self.parent.command_path]
+
+            if isinstance(self.parent.command, Command):
+                for param in self.parent.command.get_params(self):
+                    parent_command_path.extend(param.get_usage_pieces(self))
+
+            rv = f"{' '.join(parent_command_path)} {rv}"
+        return rv.lstrip()
+
+    def find_root(self) -> "Context":
+        """Finds the outermost context."""
+        node = self
+        while node.parent is not None:
+            node = node.parent
+        return node
+
+    def find_object(self, object_type: t.Type[V]) -> t.Optional[V]:
+        """Finds the closest object of a given type."""
+        node: t.Optional[Context] = self
+
+        while node is not None:
+            if isinstance(node.obj, object_type):
+                return node.obj
+
+            node = node.parent
+
+        return None
+
+    def ensure_object(self, object_type: t.Type[V]) -> V:
+        """Like :meth:`find_object` but sets the innermost object to a
+        new instance of `object_type` if it does not exist.
+        """
+        rv = self.find_object(object_type)
+        if rv is None:
+            self.obj = rv = object_type()
+        return rv
+
+    @t.overload
+    def lookup_default(
+        self, name: str, call: "te.Literal[True]" = True
+    ) -> t.Optional[t.Any]: ...
+
+    @t.overload
+    def lookup_default(
+        self, name: str, call: "te.Literal[False]" = ...
+    ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: ...
+
+    def lookup_default(self, name: str, call: bool = True) -> t.Optional[t.Any]:
+        """Get the default for a parameter from :attr:`default_map`.
+
+        :param name: Name of the parameter.
+        :param call: If the default is a callable, call it. Disable to
+            return the callable instead.
+
+        .. versionchanged:: 8.0
+            Added the ``call`` parameter.
+        """
+        if self.default_map is not None:
+            value = self.default_map.get(name)
+
+            if call and callable(value):
+                return value()
+
+            return value
+
+        return None
+
+    def fail(self, message: str) -> "te.NoReturn":
+        """Aborts the execution of the program with a specific error
+        message.
+
+        :param message: the error message to fail with.
+        """
+        raise UsageError(message, self)
+
+    def abort(self) -> "te.NoReturn":
+        """Aborts the script."""
+        raise Abort()
+
+    def exit(self, code: int = 0) -> "te.NoReturn":
+        """Exits the application with a given exit code."""
+        raise Exit(code)
+
+    def get_usage(self) -> str:
+        """Helper method to get formatted usage string for the current
+        context and command.
+        """
+        return self.command.get_usage(self)
+
+    def get_help(self) -> str:
+        """Helper method to get formatted help page for the current
+        context and command.
+        """
+        return self.command.get_help(self)
+
+    def _make_sub_context(self, command: "Command") -> "Context":
+        """Create a new context of the same type as this context, but
+        for a new command.
+
+        :meta private:
+        """
+        return type(self)(command, info_name=command.name, parent=self)
+
+    @t.overload
+    def invoke(
+        __self,
+        __callback: "t.Callable[..., V]",
+        *args: t.Any,
+        **kwargs: t.Any,
+    ) -> V: ...
+
+    @t.overload
+    def invoke(
+        __self,
+        __callback: "Command",
+        *args: t.Any,
+        **kwargs: t.Any,
+    ) -> t.Any: ...
+
+    def invoke(
+        __self,
+        __callback: t.Union["Command", "t.Callable[..., V]"],
+        *args: t.Any,
+        **kwargs: t.Any,
+    ) -> t.Union[t.Any, V]:
+        """Invokes a command callback in exactly the way it expects.  There
+        are two ways to invoke this method:
+
+        1.  the first argument can be a callback and all other arguments and
+            keyword arguments are forwarded directly to the function.
+        2.  the first argument is a click command object.  In that case all
+            arguments are forwarded as well but proper click parameters
+            (options and click arguments) must be keyword arguments and Click
+            will fill in defaults.
+
+        Note that before Click 3.2 keyword arguments were not properly filled
+        in against the intention of this code and no context was created.  For
+        more information about this change and why it was done in a bugfix
+        release see :ref:`upgrade-to-3.2`.
+
+        .. versionchanged:: 8.0
+            All ``kwargs`` are tracked in :attr:`params` so they will be
+            passed if :meth:`forward` is called at multiple levels.
+        """
+        if isinstance(__callback, Command):
+            other_cmd = __callback
+
+            if other_cmd.callback is None:
+                raise TypeError(
+                    "The given command does not have a callback that can be invoked."
+                )
+            else:
+                __callback = t.cast("t.Callable[..., V]", other_cmd.callback)
+
+            ctx = __self._make_sub_context(other_cmd)
+
+            for param in other_cmd.params:
+                if param.name not in kwargs and param.expose_value:
+                    kwargs[param.name] = param.type_cast_value(  # type: ignore
+                        ctx, param.get_default(ctx)
+                    )
+
+            # Track all kwargs as params, so that forward() will pass
+            # them on in subsequent calls.
+            ctx.params.update(kwargs)
+        else:
+            ctx = __self
+
+        with augment_usage_errors(__self):
+            with ctx:
+                return __callback(*args, **kwargs)
+
+    def forward(__self, __cmd: "Command", *args: t.Any, **kwargs: t.Any) -> t.Any:
+        """Similar to :meth:`invoke` but fills in default keyword
+        arguments from the current context if the other command expects
+        it.  This cannot invoke callbacks directly, only other commands.
+
+        .. versionchanged:: 8.0
+            All ``kwargs`` are tracked in :attr:`params` so they will be
+            passed if ``forward`` is called at multiple levels.
+        """
+        # Can only forward to other commands, not direct callbacks.
+        if not isinstance(__cmd, Command):
+            raise TypeError("Callback is not a command.")
+
+        for param in __self.params:
+            if param not in kwargs:
+                kwargs[param] = __self.params[param]
+
+        return __self.invoke(__cmd, *args, **kwargs)
+
+    def set_parameter_source(self, name: str, source: ParameterSource) -> None:
+        """Set the source of a parameter. This indicates the location
+        from which the value of the parameter was obtained.
+
+        :param name: The name of the parameter.
+        :param source: A member of :class:`~click.core.ParameterSource`.
+        """
+        self._parameter_source[name] = source
+
+    def get_parameter_source(self, name: str) -> t.Optional[ParameterSource]:
+        """Get the source of a parameter. This indicates the location
+        from which the value of the parameter was obtained.
+
+        This can be useful for determining when a user specified a value
+        on the command line that is the same as the default value. It
+        will be :attr:`~click.core.ParameterSource.DEFAULT` only if the
+        value was actually taken from the default.
+
+        :param name: The name of the parameter.
+        :rtype: ParameterSource
+
+        .. versionchanged:: 8.0
+            Returns ``None`` if the parameter was not provided from any
+            source.
+        """
+        return self._parameter_source.get(name)
+
+
+class BaseCommand:
+    """The base command implements the minimal API contract of commands.
+    Most code will never use this as it does not implement a lot of useful
+    functionality but it can act as the direct subclass of alternative
+    parsing methods that do not depend on the Click parser.
+
+    For instance, this can be used to bridge Click and other systems like
+    argparse or docopt.
+
+    Because base commands do not implement a lot of the API that other
+    parts of Click take for granted, they are not supported for all
+    operations.  For instance, they cannot be used with the decorators
+    usually and they have no built-in callback system.
+
+    .. versionchanged:: 2.0
+       Added the `context_settings` parameter.
+
+    :param name: the name of the command to use unless a group overrides it.
+    :param context_settings: an optional dictionary with defaults that are
+                             passed to the context object.
+    """
+
+    #: The context class to create with :meth:`make_context`.
+    #:
+    #: .. versionadded:: 8.0
+    context_class: t.Type[Context] = Context
+    #: the default for the :attr:`Context.allow_extra_args` flag.
+    allow_extra_args = False
+    #: the default for the :attr:`Context.allow_interspersed_args` flag.
+    allow_interspersed_args = True
+    #: the default for the :attr:`Context.ignore_unknown_options` flag.
+    ignore_unknown_options = False
+
+    def __init__(
+        self,
+        name: t.Optional[str],
+        context_settings: t.Optional[t.MutableMapping[str, t.Any]] = None,
+    ) -> None:
+        #: the name the command thinks it has.  Upon registering a command
+        #: on a :class:`Group` the group will default the command name
+        #: with this information.  You should instead use the
+        #: :class:`Context`\'s :attr:`~Context.info_name` attribute.
+        self.name = name
+
+        if context_settings is None:
+            context_settings = {}
+
+        #: an optional dictionary with defaults passed to the context.
+        self.context_settings: t.MutableMapping[str, t.Any] = context_settings
+
+    def to_info_dict(self, ctx: Context) -> t.Dict[str, t.Any]:
+        """Gather information that could be useful for a tool generating
+        user-facing documentation. This traverses the entire structure
+        below this command.
+
+        Use :meth:`click.Context.to_info_dict` to traverse the entire
+        CLI structure.
+
+        :param ctx: A :class:`Context` representing this command.
+
+        .. versionadded:: 8.0
+        """
+        return {"name": self.name}
+
+    def __repr__(self) -> str:
+        return f"<{self.__class__.__name__} {self.name}>"
+
+    def get_usage(self, ctx: Context) -> str:
+        raise NotImplementedError("Base commands cannot get usage")
+
+    def get_help(self, ctx: Context) -> str:
+        raise NotImplementedError("Base commands cannot get help")
+
+    def make_context(
+        self,
+        info_name: t.Optional[str],
+        args: t.List[str],
+        parent: t.Optional[Context] = None,
+        **extra: t.Any,
+    ) -> Context:
+        """This function when given an info name and arguments will kick
+        off the parsing and create a new :class:`Context`.  It does not
+        invoke the actual command callback though.
+
+        To quickly customize the context class used without overriding
+        this method, set the :attr:`context_class` attribute.
+
+        :param info_name: the info name for this invocation.  Generally this
+                          is the most descriptive name for the script or
+                          command.  For the toplevel script it's usually
+                          the name of the script, for commands below it's
+                          the name of the command.
+        :param args: the arguments to parse as list of strings.
+        :param parent: the parent context if available.
+        :param extra: extra keyword arguments forwarded to the context
+                      constructor.
+
+        .. versionchanged:: 8.0
+            Added the :attr:`context_class` attribute.
+        """
+        for key, value in self.context_settings.items():
+            if key not in extra:
+                extra[key] = value
+
+        ctx = self.context_class(
+            self,  # type: ignore[arg-type]
+            info_name=info_name,
+            parent=parent,
+            **extra,
+        )
+
+        with ctx.scope(cleanup=False):
+            self.parse_args(ctx, args)
+        return ctx
+
+    def parse_args(self, ctx: Context, args: t.List[str]) -> t.List[str]:
+        """Given a context and a list of arguments this creates the parser
+        and parses the arguments, then modifies the context as necessary.
+        This is automatically invoked by :meth:`make_context`.
+        """
+        raise NotImplementedError("Base commands do not know how to parse arguments.")
+
+    def invoke(self, ctx: Context) -> t.Any:
+        """Given a context, this invokes the command.  The default
+        implementation is raising a not implemented error.
+        """
+        raise NotImplementedError("Base commands are not invocable by default")
+
+    def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]:
+        """Return a list of completions for the incomplete value. Looks
+        at the names of chained multi-commands.
+
+        Any command could be part of a chained multi-command, so sibling
+        commands are valid at any point during command completion. Other
+        command classes will return more completions.
+
+        :param ctx: Invocation context for this command.
+        :param incomplete: Value being completed. May be empty.
+
+        .. versionadded:: 8.0
+        """
+        from click.shell_completion import CompletionItem
+
+        results: t.List[CompletionItem] = []
+
+        while ctx.parent is not None:
+            ctx = ctx.parent
+
+            if isinstance(ctx.command, MultiCommand) and ctx.command.chain:
+                results.extend(
+                    CompletionItem(name, help=command.get_short_help_str())
+                    for name, command in _complete_visible_commands(ctx, incomplete)
+                    if name not in ctx.protected_args
+                )
+
+        return results
+
+    @t.overload
+    def main(
+        self,
+        args: t.Optional[t.Sequence[str]] = None,
+        prog_name: t.Optional[str] = None,
+        complete_var: t.Optional[str] = None,
+        standalone_mode: "te.Literal[True]" = True,
+        **extra: t.Any,
+    ) -> "te.NoReturn": ...
+
+    @t.overload
+    def main(
+        self,
+        args: t.Optional[t.Sequence[str]] = None,
+        prog_name: t.Optional[str] = None,
+        complete_var: t.Optional[str] = None,
+        standalone_mode: bool = ...,
+        **extra: t.Any,
+    ) -> t.Any: ...
+
+    def main(
+        self,
+        args: t.Optional[t.Sequence[str]] = None,
+        prog_name: t.Optional[str] = None,
+        complete_var: t.Optional[str] = None,
+        standalone_mode: bool = True,
+        windows_expand_args: bool = True,
+        **extra: t.Any,
+    ) -> t.Any:
+        """This is the way to invoke a script with all the bells and
+        whistles as a command line application.  This will always terminate
+        the application after a call.  If this is not wanted, ``SystemExit``
+        needs to be caught.
+
+        This method is also available by directly calling the instance of
+        a :class:`Command`.
+
+        :param args: the arguments that should be used for parsing.  If not
+                     provided, ``sys.argv[1:]`` is used.
+        :param prog_name: the program name that should be used.  By default
+                          the program name is constructed by taking the file
+                          name from ``sys.argv[0]``.
+        :param complete_var: the environment variable that controls the
+                             bash completion support.  The default is
+                             ``"_<prog_name>_COMPLETE"`` with prog_name in
+                             uppercase.
+        :param standalone_mode: the default behavior is to invoke the script
+                                in standalone mode.  Click will then
+                                handle exceptions and convert them into
+                                error messages and the function will never
+                                return but shut down the interpreter.  If
+                                this is set to `False` they will be
+                                propagated to the caller and the return
+                                value of this function is the return value
+                                of :meth:`invoke`.
+        :param windows_expand_args: Expand glob patterns, user dir, and
+            env vars in command line args on Windows.
+        :param extra: extra keyword arguments are forwarded to the context
+                      constructor.  See :class:`Context` for more information.
+
+        .. versionchanged:: 8.0.1
+            Added the ``windows_expand_args`` parameter to allow
+            disabling command line arg expansion on Windows.
+
+        .. versionchanged:: 8.0
+            When taking arguments from ``sys.argv`` on Windows, glob
+            patterns, user dir, and env vars are expanded.
+
+        .. versionchanged:: 3.0
+           Added the ``standalone_mode`` parameter.
+        """
+        if args is None:
+            args = sys.argv[1:]
+
+            if os.name == "nt" and windows_expand_args:
+                args = _expand_args(args)
+        else:
+            args = list(args)
+
+        if prog_name is None:
+            prog_name = _detect_program_name()
+
+        # Process shell completion requests and exit early.
+        self._main_shell_completion(extra, prog_name, complete_var)
+
+        try:
+            try:
+                with self.make_context(prog_name, args, **extra) as ctx:
+                    rv = self.invoke(ctx)
+                    if not standalone_mode:
+                        return rv
+                    # it's not safe to `ctx.exit(rv)` here!
+                    # note that `rv` may actually contain data like "1" which
+                    # has obvious effects
+                    # more subtle case: `rv=[None, None]` can come out of
+                    # chained commands which all returned `None` -- so it's not
+                    # even always obvious that `rv` indicates success/failure
+                    # by its truthiness/falsiness
+                    ctx.exit()
+            except (EOFError, KeyboardInterrupt) as e:
+                echo(file=sys.stderr)
+                raise Abort() from e
+            except ClickException as e:
+                if not standalone_mode:
+                    raise
+                e.show()
+                sys.exit(e.exit_code)
+            except OSError as e:
+                if e.errno == errno.EPIPE:
+                    sys.stdout = t.cast(t.TextIO, PacifyFlushWrapper(sys.stdout))
+                    sys.stderr = t.cast(t.TextIO, PacifyFlushWrapper(sys.stderr))
+                    sys.exit(1)
+                else:
+                    raise
+        except Exit as e:
+            if standalone_mode:
+                sys.exit(e.exit_code)
+            else:
+                # in non-standalone mode, return the exit code
+                # note that this is only reached if `self.invoke` above raises
+                # an Exit explicitly -- thus bypassing the check there which
+                # would return its result
+                # the results of non-standalone execution may therefore be
+                # somewhat ambiguous: if there are codepaths which lead to
+                # `ctx.exit(1)` and to `return 1`, the caller won't be able to
+                # tell the difference between the two
+                return e.exit_code
+        except Abort:
+            if not standalone_mode:
+                raise
+            echo(_("Aborted!"), file=sys.stderr)
+            sys.exit(1)
+
+    def _main_shell_completion(
+        self,
+        ctx_args: t.MutableMapping[str, t.Any],
+        prog_name: str,
+        complete_var: t.Optional[str] = None,
+    ) -> None:
+        """Check if the shell is asking for tab completion, process
+        that, then exit early. Called from :meth:`main` before the
+        program is invoked.
+
+        :param prog_name: Name of the executable in the shell.
+        :param complete_var: Name of the environment variable that holds
+            the completion instruction. Defaults to
+            ``_{PROG_NAME}_COMPLETE``.
+
+        .. versionchanged:: 8.2.0
+            Dots (``.``) in ``prog_name`` are replaced with underscores (``_``).
+        """
+        if complete_var is None:
+            complete_name = prog_name.replace("-", "_").replace(".", "_")
+            complete_var = f"_{complete_name}_COMPLETE".upper()
+
+        instruction = os.environ.get(complete_var)
+
+        if not instruction:
+            return
+
+        from .shell_completion import shell_complete
+
+        rv = shell_complete(self, ctx_args, prog_name, complete_var, instruction)
+        sys.exit(rv)
+
+    def __call__(self, *args: t.Any, **kwargs: t.Any) -> t.Any:
+        """Alias for :meth:`main`."""
+        return self.main(*args, **kwargs)
+
+
+class Command(BaseCommand):
+    """Commands are the basic building block of command line interfaces in
+    Click.  A basic command handles command line parsing and might dispatch
+    more parsing to commands nested below it.
+
+    :param name: the name of the command to use unless a group overrides it.
+    :param context_settings: an optional dictionary with defaults that are
+                             passed to the context object.
+    :param callback: the callback to invoke.  This is optional.
+    :param params: the parameters to register with this command.  This can
+                   be either :class:`Option` or :class:`Argument` objects.
+    :param help: the help string to use for this command.
+    :param epilog: like the help string but it's printed at the end of the
+                   help page after everything else.
+    :param short_help: the short help to use for this command.  This is
+                       shown on the command listing of the parent command.
+    :param add_help_option: by default each command registers a ``--help``
+                            option.  This can be disabled by this parameter.
+    :param no_args_is_help: this controls what happens if no arguments are
+                            provided.  This option is disabled by default.
+                            If enabled this will add ``--help`` as argument
+                            if no arguments are passed
+    :param hidden: hide this command from help outputs.
+
+    :param deprecated: issues a message indicating that
+                             the command is deprecated.
+
+    .. versionchanged:: 8.1
+        ``help``, ``epilog``, and ``short_help`` are stored unprocessed,
+        all formatting is done when outputting help text, not at init,
+        and is done even if not using the ``@command`` decorator.
+
+    .. versionchanged:: 8.0
+        Added a ``repr`` showing the command name.
+
+    .. versionchanged:: 7.1
+        Added the ``no_args_is_help`` parameter.
+
+    .. versionchanged:: 2.0
+        Added the ``context_settings`` parameter.
+    """
+
+    def __init__(
+        self,
+        name: t.Optional[str],
+        context_settings: t.Optional[t.MutableMapping[str, t.Any]] = None,
+        callback: t.Optional[t.Callable[..., t.Any]] = None,
+        params: t.Optional[t.List["Parameter"]] = None,
+        help: t.Optional[str] = None,
+        epilog: t.Optional[str] = None,
+        short_help: t.Optional[str] = None,
+        options_metavar: t.Optional[str] = "[OPTIONS]",
+        add_help_option: bool = True,
+        no_args_is_help: bool = False,
+        hidden: bool = False,
+        deprecated: bool = False,
+    ) -> None:
+        super().__init__(name, context_settings)
+        #: the callback to execute when the command fires.  This might be
+        #: `None` in which case nothing happens.
+        self.callback = callback
+        #: the list of parameters for this command in the order they
+        #: should show up in the help page and execute.  Eager parameters
+        #: will automatically be handled before non eager ones.
+        self.params: t.List[Parameter] = params or []
+        self.help = help
+        self.epilog = epilog
+        self.options_metavar = options_metavar
+        self.short_help = short_help
+        self.add_help_option = add_help_option
+        self._help_option: t.Optional[HelpOption] = None
+        self.no_args_is_help = no_args_is_help
+        self.hidden = hidden
+        self.deprecated = deprecated
+
+    def to_info_dict(self, ctx: Context) -> t.Dict[str, t.Any]:
+        info_dict = super().to_info_dict(ctx)
+        info_dict.update(
+            params=[param.to_info_dict() for param in self.get_params(ctx)],
+            help=self.help,
+            epilog=self.epilog,
+            short_help=self.short_help,
+            hidden=self.hidden,
+            deprecated=self.deprecated,
+        )
+        return info_dict
+
+    def get_usage(self, ctx: Context) -> str:
+        """Formats the usage line into a string and returns it.
+
+        Calls :meth:`format_usage` internally.
+        """
+        formatter = ctx.make_formatter()
+        self.format_usage(ctx, formatter)
+        return formatter.getvalue().rstrip("\n")
+
+    def get_params(self, ctx: Context) -> t.List["Parameter"]:
+        rv = self.params
+        help_option = self.get_help_option(ctx)
+
+        if help_option is not None:
+            rv = [*rv, help_option]
+
+        return rv
+
+    def format_usage(self, ctx: Context, formatter: HelpFormatter) -> None:
+        """Writes the usage line into the formatter.
+
+        This is a low-level method called by :meth:`get_usage`.
+        """
+        pieces = self.collect_usage_pieces(ctx)
+        formatter.write_usage(ctx.command_path, " ".join(pieces))
+
+    def collect_usage_pieces(self, ctx: Context) -> t.List[str]:
+        """Returns all the pieces that go into the usage line and returns
+        it as a list of strings.
+        """
+        rv = [self.options_metavar] if self.options_metavar else []
+
+        for param in self.get_params(ctx):
+            rv.extend(param.get_usage_pieces(ctx))
+
+        return rv
+
+    def get_help_option_names(self, ctx: Context) -> t.List[str]:
+        """Returns the names for the help option."""
+        all_names = set(ctx.help_option_names)
+        for param in self.params:
+            all_names.difference_update(param.opts)
+            all_names.difference_update(param.secondary_opts)
+        return list(all_names)
+
+    def get_help_option(self, ctx: Context) -> t.Optional["Option"]:
+        """Returns the help option object.
+
+        Unless ``add_help_option`` is ``False``.
+
+        .. versionchanged:: 8.1.8
+            The help option is now cached to avoid creating it multiple times.
+        """
+        help_options = self.get_help_option_names(ctx)
+
+        if not help_options or not self.add_help_option:
+            return None
+
+        # Cache the help option object in private _help_option attribute to
+        # avoid creating it multiple times. Not doing this will break the
+        # callback odering by iter_params_for_processing(), which relies on
+        # object comparison.
+        if self._help_option is None:
+            # Avoid circular import.
+            from .decorators import HelpOption
+
+            self._help_option = HelpOption(help_options)
+
+        return self._help_option
+
+    def make_parser(self, ctx: Context) -> OptionParser:
+        """Creates the underlying option parser for this command."""
+        parser = OptionParser(ctx)
+        for param in self.get_params(ctx):
+            param.add_to_parser(parser, ctx)
+        return parser
+
+    def get_help(self, ctx: Context) -> str:
+        """Formats the help into a string and returns it.
+
+        Calls :meth:`format_help` internally.
+        """
+        formatter = ctx.make_formatter()
+        self.format_help(ctx, formatter)
+        return formatter.getvalue().rstrip("\n")
+
+    def get_short_help_str(self, limit: int = 45) -> str:
+        """Gets short help for the command or makes it by shortening the
+        long help string.
+        """
+        if self.short_help:
+            text = inspect.cleandoc(self.short_help)
+        elif self.help:
+            text = make_default_short_help(self.help, limit)
+        else:
+            text = ""
+
+        if self.deprecated:
+            text = _("(Deprecated) {text}").format(text=text)
+
+        return text.strip()
+
+    def format_help(self, ctx: Context, formatter: HelpFormatter) -> None:
+        """Writes the help into the formatter if it exists.
+
+        This is a low-level method called by :meth:`get_help`.
+
+        This calls the following methods:
+
+        -   :meth:`format_usage`
+        -   :meth:`format_help_text`
+        -   :meth:`format_options`
+        -   :meth:`format_epilog`
+        """
+        self.format_usage(ctx, formatter)
+        self.format_help_text(ctx, formatter)
+        self.format_options(ctx, formatter)
+        self.format_epilog(ctx, formatter)
+
+    def format_help_text(self, ctx: Context, formatter: HelpFormatter) -> None:
+        """Writes the help text to the formatter if it exists."""
+        if self.help is not None:
+            # truncate the help text to the first form feed
+            text = inspect.cleandoc(self.help).partition("\f")[0]
+        else:
+            text = ""
+
+        if self.deprecated:
+            text = _("(Deprecated) {text}").format(text=text)
+
+        if text:
+            formatter.write_paragraph()
+
+            with formatter.indentation():
+                formatter.write_text(text)
+
+    def format_options(self, ctx: Context, formatter: HelpFormatter) -> None:
+        """Writes all the options into the formatter if they exist."""
+        opts = []
+        for param in self.get_params(ctx):
+            rv = param.get_help_record(ctx)
+            if rv is not None:
+                opts.append(rv)
+
+        if opts:
+            with formatter.section(_("Options")):
+                formatter.write_dl(opts)
+
+    def format_epilog(self, ctx: Context, formatter: HelpFormatter) -> None:
+        """Writes the epilog into the formatter if it exists."""
+        if self.epilog:
+            epilog = inspect.cleandoc(self.epilog)
+            formatter.write_paragraph()
+
+            with formatter.indentation():
+                formatter.write_text(epilog)
+
+    def parse_args(self, ctx: Context, args: t.List[str]) -> t.List[str]:
+        if not args and self.no_args_is_help and not ctx.resilient_parsing:
+            echo(ctx.get_help(), color=ctx.color)
+            ctx.exit()
+
+        parser = self.make_parser(ctx)
+        opts, args, param_order = parser.parse_args(args=args)
+
+        for param in iter_params_for_processing(param_order, self.get_params(ctx)):
+            value, args = param.handle_parse_result(ctx, opts, args)
+
+        if args and not ctx.allow_extra_args and not ctx.resilient_parsing:
+            ctx.fail(
+                ngettext(
+                    "Got unexpected extra argument ({args})",
+                    "Got unexpected extra arguments ({args})",
+                    len(args),
+                ).format(args=" ".join(map(str, args)))
+            )
+
+        ctx.args = args
+        ctx._opt_prefixes.update(parser._opt_prefixes)
+        return args
+
+    def invoke(self, ctx: Context) -> t.Any:
+        """Given a context, this invokes the attached callback (if it exists)
+        in the right way.
+        """
+        if self.deprecated:
+            message = _(
+                "DeprecationWarning: The command {name!r} is deprecated."
+            ).format(name=self.name)
+            echo(style(message, fg="red"), err=True)
+
+        if self.callback is not None:
+            return ctx.invoke(self.callback, **ctx.params)
+
+    def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]:
+        """Return a list of completions for the incomplete value. Looks
+        at the names of options and chained multi-commands.
+
+        :param ctx: Invocation context for this command.
+        :param incomplete: Value being completed. May be empty.
+
+        .. versionadded:: 8.0
+        """
+        from click.shell_completion import CompletionItem
+
+        results: t.List[CompletionItem] = []
+
+        if incomplete and not incomplete[0].isalnum():
+            for param in self.get_params(ctx):
+                if (
+                    not isinstance(param, Option)
+                    or param.hidden
+                    or (
+                        not param.multiple
+                        and ctx.get_parameter_source(param.name)  # type: ignore
+                        is ParameterSource.COMMANDLINE
+                    )
+                ):
+                    continue
+
+                results.extend(
+                    CompletionItem(name, help=param.help)
+                    for name in [*param.opts, *param.secondary_opts]
+                    if name.startswith(incomplete)
+                )
+
+        results.extend(super().shell_complete(ctx, incomplete))
+        return results
+
+
+class MultiCommand(Command):
+    """A multi command is the basic implementation of a command that
+    dispatches to subcommands.  The most common version is the
+    :class:`Group`.
+
+    :param invoke_without_command: this controls how the multi command itself
+                                   is invoked.  By default it's only invoked
+                                   if a subcommand is provided.
+    :param no_args_is_help: this controls what happens if no arguments are
+                            provided.  This option is enabled by default if
+                            `invoke_without_command` is disabled or disabled
+                            if it's enabled.  If enabled this will add
+                            ``--help`` as argument if no arguments are
+                            passed.
+    :param subcommand_metavar: the string that is used in the documentation
+                               to indicate the subcommand place.
+    :param chain: if this is set to `True` chaining of multiple subcommands
+                  is enabled.  This restricts the form of commands in that
+                  they cannot have optional arguments but it allows
+                  multiple commands to be chained together.
+    :param result_callback: The result callback to attach to this multi
+        command. This can be set or changed later with the
+        :meth:`result_callback` decorator.
+    :param attrs: Other command arguments described in :class:`Command`.
+    """
+
+    allow_extra_args = True
+    allow_interspersed_args = False
+
+    def __init__(
+        self,
+        name: t.Optional[str] = None,
+        invoke_without_command: bool = False,
+        no_args_is_help: t.Optional[bool] = None,
+        subcommand_metavar: t.Optional[str] = None,
+        chain: bool = False,
+        result_callback: t.Optional[t.Callable[..., t.Any]] = None,
+        **attrs: t.Any,
+    ) -> None:
+        super().__init__(name, **attrs)
+
+        if no_args_is_help is None:
+            no_args_is_help = not invoke_without_command
+
+        self.no_args_is_help = no_args_is_help
+        self.invoke_without_command = invoke_without_command
+
+        if subcommand_metavar is None:
+            if chain:
+                subcommand_metavar = "COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]..."
+            else:
+                subcommand_metavar = "COMMAND [ARGS]..."
+
+        self.subcommand_metavar = subcommand_metavar
+        self.chain = chain
+        # The result callback that is stored. This can be set or
+        # overridden with the :func:`result_callback` decorator.
+        self._result_callback = result_callback
+
+        if self.chain:
+            for param in self.params:
+                if isinstance(param, Argument) and not param.required:
+                    raise RuntimeError(
+                        "Multi commands in chain mode cannot have"
+                        " optional arguments."
+                    )
+
+    def to_info_dict(self, ctx: Context) -> t.Dict[str, t.Any]:
+        info_dict = super().to_info_dict(ctx)
+        commands = {}
+
+        for name in self.list_commands(ctx):
+            command = self.get_command(ctx, name)
+
+            if command is None:
+                continue
+
+            sub_ctx = ctx._make_sub_context(command)
+
+            with sub_ctx.scope(cleanup=False):
+                commands[name] = command.to_info_dict(sub_ctx)
+
+        info_dict.update(commands=commands, chain=self.chain)
+        return info_dict
+
+    def collect_usage_pieces(self, ctx: Context) -> t.List[str]:
+        rv = super().collect_usage_pieces(ctx)
+        rv.append(self.subcommand_metavar)
+        return rv
+
+    def format_options(self, ctx: Context, formatter: HelpFormatter) -> None:
+        super().format_options(ctx, formatter)
+        self.format_commands(ctx, formatter)
+
+    def result_callback(self, replace: bool = False) -> t.Callable[[F], F]:
+        """Adds a result callback to the command.  By default if a
+        result callback is already registered this will chain them but
+        this can be disabled with the `replace` parameter.  The result
+        callback is invoked with the return value of the subcommand
+        (or the list of return values from all subcommands if chaining
+        is enabled) as well as the parameters as they would be passed
+        to the main callback.
+
+        Example::
+
+            @click.group()
+            @click.option('-i', '--input', default=23)
+            def cli(input):
+                return 42
+
+            @cli.result_callback()
+            def process_result(result, input):
+                return result + input
+
+        :param replace: if set to `True` an already existing result
+                        callback will be removed.
+
+        .. versionchanged:: 8.0
+            Renamed from ``resultcallback``.
+
+        .. versionadded:: 3.0
+        """
+
+        def decorator(f: F) -> F:
+            old_callback = self._result_callback
+
+            if old_callback is None or replace:
+                self._result_callback = f
+                return f
+
+            def function(__value, *args, **kwargs):  # type: ignore
+                inner = old_callback(__value, *args, **kwargs)
+                return f(inner, *args, **kwargs)
+
+            self._result_callback = rv = update_wrapper(t.cast(F, function), f)
+            return rv  # type: ignore[return-value]
+
+        return decorator
+
+    def format_commands(self, ctx: Context, formatter: HelpFormatter) -> None:
+        """Extra format methods for multi methods that adds all the commands
+        after the options.
+        """
+        commands = []
+        for subcommand in self.list_commands(ctx):
+            cmd = self.get_command(ctx, subcommand)
+            # What is this, the tool lied about a command.  Ignore it
+            if cmd is None:
+                continue
+            if cmd.hidden:
+                continue
+
+            commands.append((subcommand, cmd))
+
+        # allow for 3 times the default spacing
+        if len(commands):
+            limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands)
+
+            rows = []
+            for subcommand, cmd in commands:
+                help = cmd.get_short_help_str(limit)
+                rows.append((subcommand, help))
+
+            if rows:
+                with formatter.section(_("Commands")):
+                    formatter.write_dl(rows)
+
+    def parse_args(self, ctx: Context, args: t.List[str]) -> t.List[str]:
+        if not args and self.no_args_is_help and not ctx.resilient_parsing:
+            echo(ctx.get_help(), color=ctx.color)
+            ctx.exit()
+
+        rest = super().parse_args(ctx, args)
+
+        if self.chain:
+            ctx.protected_args = rest
+            ctx.args = []
+        elif rest:
+            ctx.protected_args, ctx.args = rest[:1], rest[1:]
+
+        return ctx.args
+
+    def invoke(self, ctx: Context) -> t.Any:
+        def _process_result(value: t.Any) -> t.Any:
+            if self._result_callback is not None:
+                value = ctx.invoke(self._result_callback, value, **ctx.params)
+            return value
+
+        if not ctx.protected_args:
+            if self.invoke_without_command:
+                # No subcommand was invoked, so the result callback is
+                # invoked with the group return value for regular
+                # groups, or an empty list for chained groups.
+                with ctx:
+                    rv = super().invoke(ctx)
+                    return _process_result([] if self.chain else rv)
+            ctx.fail(_("Missing command."))
+
+        # Fetch args back out
+        args = [*ctx.protected_args, *ctx.args]
+        ctx.args = []
+        ctx.protected_args = []
+
+        # If we're not in chain mode, we only allow the invocation of a
+        # single command but we also inform the current context about the
+        # name of the command to invoke.
+        if not self.chain:
+            # Make sure the context is entered so we do not clean up
+            # resources until the result processor has worked.
+            with ctx:
+                cmd_name, cmd, args = self.resolve_command(ctx, args)
+                assert cmd is not None
+                ctx.invoked_subcommand = cmd_name
+                super().invoke(ctx)
+                sub_ctx = cmd.make_context(cmd_name, args, parent=ctx)
+                with sub_ctx:
+                    return _process_result(sub_ctx.command.invoke(sub_ctx))
+
+        # In chain mode we create the contexts step by step, but after the
+        # base command has been invoked.  Because at that point we do not
+        # know the subcommands yet, the invoked subcommand attribute is
+        # set to ``*`` to inform the command that subcommands are executed
+        # but nothing else.
+        with ctx:
+            ctx.invoked_subcommand = "*" if args else None
+            super().invoke(ctx)
+
+            # Otherwise we make every single context and invoke them in a
+            # chain.  In that case the return value to the result processor
+            # is the list of all invoked subcommand's results.
+            contexts = []
+            while args:
+                cmd_name, cmd, args = self.resolve_command(ctx, args)
+                assert cmd is not None
+                sub_ctx = cmd.make_context(
+                    cmd_name,
+                    args,
+                    parent=ctx,
+                    allow_extra_args=True,
+                    allow_interspersed_args=False,
+                )
+                contexts.append(sub_ctx)
+                args, sub_ctx.args = sub_ctx.args, []
+
+            rv = []
+            for sub_ctx in contexts:
+                with sub_ctx:
+                    rv.append(sub_ctx.command.invoke(sub_ctx))
+            return _process_result(rv)
+
+    def resolve_command(
+        self, ctx: Context, args: t.List[str]
+    ) -> t.Tuple[t.Optional[str], t.Optional[Command], t.List[str]]:
+        cmd_name = make_str(args[0])
+        original_cmd_name = cmd_name
+
+        # Get the command
+        cmd = self.get_command(ctx, cmd_name)
+
+        # If we can't find the command but there is a normalization
+        # function available, we try with that one.
+        if cmd is None and ctx.token_normalize_func is not None:
+            cmd_name = ctx.token_normalize_func(cmd_name)
+            cmd = self.get_command(ctx, cmd_name)
+
+        # If we don't find the command we want to show an error message
+        # to the user that it was not provided.  However, there is
+        # something else we should do: if the first argument looks like
+        # an option we want to kick off parsing again for arguments to
+        # resolve things like --help which now should go to the main
+        # place.
+        if cmd is None and not ctx.resilient_parsing:
+            if split_opt(cmd_name)[0]:
+                self.parse_args(ctx, ctx.args)
+            ctx.fail(_("No such command {name!r}.").format(name=original_cmd_name))
+        return cmd_name if cmd else None, cmd, args[1:]
+
+    def get_command(self, ctx: Context, cmd_name: str) -> t.Optional[Command]:
+        """Given a context and a command name, this returns a
+        :class:`Command` object if it exists or returns `None`.
+        """
+        raise NotImplementedError
+
+    def list_commands(self, ctx: Context) -> t.List[str]:
+        """Returns a list of subcommand names in the order they should
+        appear.
+        """
+        return []
+
+    def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]:
+        """Return a list of completions for the incomplete value. Looks
+        at the names of options, subcommands, and chained
+        multi-commands.
+
+        :param ctx: Invocation context for this command.
+        :param incomplete: Value being completed. May be empty.
+
+        .. versionadded:: 8.0
+        """
+        from click.shell_completion import CompletionItem
+
+        results = [
+            CompletionItem(name, help=command.get_short_help_str())
+            for name, command in _complete_visible_commands(ctx, incomplete)
+        ]
+        results.extend(super().shell_complete(ctx, incomplete))
+        return results
+
+
+class Group(MultiCommand):
+    """A group allows a command to have subcommands attached. This is
+    the most common way to implement nesting in Click.
+
+    :param name: The name of the group command.
+    :param commands: A dict mapping names to :class:`Command` objects.
+        Can also be a list of :class:`Command`, which will use
+        :attr:`Command.name` to create the dict.
+    :param attrs: Other command arguments described in
+        :class:`MultiCommand`, :class:`Command`, and
+        :class:`BaseCommand`.
+
+    .. versionchanged:: 8.0
+        The ``commands`` argument can be a list of command objects.
+    """
+
+    #: If set, this is used by the group's :meth:`command` decorator
+    #: as the default :class:`Command` class. This is useful to make all
+    #: subcommands use a custom command class.
+    #:
+    #: .. versionadded:: 8.0
+    command_class: t.Optional[t.Type[Command]] = None
+
+    #: If set, this is used by the group's :meth:`group` decorator
+    #: as the default :class:`Group` class. This is useful to make all
+    #: subgroups use a custom group class.
+    #:
+    #: If set to the special value :class:`type` (literally
+    #: ``group_class = type``), this group's class will be used as the
+    #: default class. This makes a custom group class continue to make
+    #: custom groups.
+    #:
+    #: .. versionadded:: 8.0
+    group_class: t.Optional[t.Union[t.Type["Group"], t.Type[type]]] = None
+    # Literal[type] isn't valid, so use Type[type]
+
+    def __init__(
+        self,
+        name: t.Optional[str] = None,
+        commands: t.Optional[
+            t.Union[t.MutableMapping[str, Command], t.Sequence[Command]]
+        ] = None,
+        **attrs: t.Any,
+    ) -> None:
+        super().__init__(name, **attrs)
+
+        if commands is None:
+            commands = {}
+        elif isinstance(commands, abc.Sequence):
+            commands = {c.name: c for c in commands if c.name is not None}
+
+        #: The registered subcommands by their exported names.
+        self.commands: t.MutableMapping[str, Command] = commands
+
+    def add_command(self, cmd: Command, name: t.Optional[str] = None) -> None:
+        """Registers another :class:`Command` with this group.  If the name
+        is not provided, the name of the command is used.
+        """
+        name = name or cmd.name
+        if name is None:
+            raise TypeError("Command has no name.")
+        _check_multicommand(self, name, cmd, register=True)
+        self.commands[name] = cmd
+
+    @t.overload
+    def command(self, __func: t.Callable[..., t.Any]) -> Command: ...
+
+    @t.overload
+    def command(
+        self, *args: t.Any, **kwargs: t.Any
+    ) -> t.Callable[[t.Callable[..., t.Any]], Command]: ...
+
+    def command(
+        self, *args: t.Any, **kwargs: t.Any
+    ) -> t.Union[t.Callable[[t.Callable[..., t.Any]], Command], Command]:
+        """A shortcut decorator for declaring and attaching a command to
+        the group. This takes the same arguments as :func:`command` and
+        immediately registers the created command with this group by
+        calling :meth:`add_command`.
+
+        To customize the command class used, set the
+        :attr:`command_class` attribute.
+
+        .. versionchanged:: 8.1
+            This decorator can be applied without parentheses.
+
+        .. versionchanged:: 8.0
+            Added the :attr:`command_class` attribute.
+        """
+        from .decorators import command
+
+        func: t.Optional[t.Callable[..., t.Any]] = None
+
+        if args and callable(args[0]):
+            assert (
+                len(args) == 1 and not kwargs
+            ), "Use 'command(**kwargs)(callable)' to provide arguments."
+            (func,) = args
+            args = ()
+
+        if self.command_class and kwargs.get("cls") is None:
+            kwargs["cls"] = self.command_class
+
+        def decorator(f: t.Callable[..., t.Any]) -> Command:
+            cmd: Command = command(*args, **kwargs)(f)
+            self.add_command(cmd)
+            return cmd
+
+        if func is not None:
+            return decorator(func)
+
+        return decorator
+
+    @t.overload
+    def group(self, __func: t.Callable[..., t.Any]) -> "Group": ...
+
+    @t.overload
+    def group(
+        self, *args: t.Any, **kwargs: t.Any
+    ) -> t.Callable[[t.Callable[..., t.Any]], "Group"]: ...
+
+    def group(
+        self, *args: t.Any, **kwargs: t.Any
+    ) -> t.Union[t.Callable[[t.Callable[..., t.Any]], "Group"], "Group"]:
+        """A shortcut decorator for declaring and attaching a group to
+        the group. This takes the same arguments as :func:`group` and
+        immediately registers the created group with this group by
+        calling :meth:`add_command`.
+
+        To customize the group class used, set the :attr:`group_class`
+        attribute.
+
+        .. versionchanged:: 8.1
+            This decorator can be applied without parentheses.
+
+        .. versionchanged:: 8.0
+            Added the :attr:`group_class` attribute.
+        """
+        from .decorators import group
+
+        func: t.Optional[t.Callable[..., t.Any]] = None
+
+        if args and callable(args[0]):
+            assert (
+                len(args) == 1 and not kwargs
+            ), "Use 'group(**kwargs)(callable)' to provide arguments."
+            (func,) = args
+            args = ()
+
+        if self.group_class is not None and kwargs.get("cls") is None:
+            if self.group_class is type:
+                kwargs["cls"] = type(self)
+            else:
+                kwargs["cls"] = self.group_class
+
+        def decorator(f: t.Callable[..., t.Any]) -> "Group":
+            cmd: Group = group(*args, **kwargs)(f)
+            self.add_command(cmd)
+            return cmd
+
+        if func is not None:
+            return decorator(func)
+
+        return decorator
+
+    def get_command(self, ctx: Context, cmd_name: str) -> t.Optional[Command]:
+        return self.commands.get(cmd_name)
+
+    def list_commands(self, ctx: Context) -> t.List[str]:
+        return sorted(self.commands)
+
+
+class CommandCollection(MultiCommand):
+    """A command collection is a multi command that merges multiple multi
+    commands together into one.  This is a straightforward implementation
+    that accepts a list of different multi commands as sources and
+    provides all the commands for each of them.
+
+    See :class:`MultiCommand` and :class:`Command` for the description of
+    ``name`` and ``attrs``.
+    """
+
+    def __init__(
+        self,
+        name: t.Optional[str] = None,
+        sources: t.Optional[t.List[MultiCommand]] = None,
+        **attrs: t.Any,
+    ) -> None:
+        super().__init__(name, **attrs)
+        #: The list of registered multi commands.
+        self.sources: t.List[MultiCommand] = sources or []
+
+    def add_source(self, multi_cmd: MultiCommand) -> None:
+        """Adds a new multi command to the chain dispatcher."""
+        self.sources.append(multi_cmd)
+
+    def get_command(self, ctx: Context, cmd_name: str) -> t.Optional[Command]:
+        for source in self.sources:
+            rv = source.get_command(ctx, cmd_name)
+
+            if rv is not None:
+                if self.chain:
+                    _check_multicommand(self, cmd_name, rv)
+
+                return rv
+
+        return None
+
+    def list_commands(self, ctx: Context) -> t.List[str]:
+        rv: t.Set[str] = set()
+
+        for source in self.sources:
+            rv.update(source.list_commands(ctx))
+
+        return sorted(rv)
+
+
+def _check_iter(value: t.Any) -> t.Iterator[t.Any]:
+    """Check if the value is iterable but not a string. Raises a type
+    error, or return an iterator over the value.
+    """
+    if isinstance(value, str):
+        raise TypeError
+
+    return iter(value)
+
+
+class Parameter:
+    r"""A parameter to a command comes in two versions: they are either
+    :class:`Option`\s or :class:`Argument`\s.  Other subclasses are currently
+    not supported by design as some of the internals for parsing are
+    intentionally not finalized.
+
+    Some settings are supported by both options and arguments.
+
+    :param param_decls: the parameter declarations for this option or
+                        argument.  This is a list of flags or argument
+                        names.
+    :param type: the type that should be used.  Either a :class:`ParamType`
+                 or a Python type.  The latter is converted into the former
+                 automatically if supported.
+    :param required: controls if this is optional or not.
+    :param default: the default value if omitted.  This can also be a callable,
+                    in which case it's invoked when the default is needed
+                    without any arguments.
+    :param callback: A function to further process or validate the value
+        after type conversion. It is called as ``f(ctx, param, value)``
+        and must return the value. It is called for all sources,
+        including prompts.
+    :param nargs: the number of arguments to match.  If not ``1`` the return
+                  value is a tuple instead of single value.  The default for
+                  nargs is ``1`` (except if the type is a tuple, then it's
+                  the arity of the tuple). If ``nargs=-1``, all remaining
+                  parameters are collected.
+    :param metavar: how the value is represented in the help page.
+    :param expose_value: if this is `True` then the value is passed onwards
+                         to the command callback and stored on the context,
+                         otherwise it's skipped.
+    :param is_eager: eager values are processed before non eager ones.  This
+                     should not be set for arguments or it will inverse the
+                     order of processing.
+    :param envvar: a string or list of strings that are environment variables
+                   that should be checked.
+    :param shell_complete: A function that returns custom shell
+        completions. Used instead of the param's type completion if
+        given. Takes ``ctx, param, incomplete`` and must return a list
+        of :class:`~click.shell_completion.CompletionItem` or a list of
+        strings.
+
+    .. versionchanged:: 8.0
+        ``process_value`` validates required parameters and bounded
+        ``nargs``, and invokes the parameter callback before returning
+        the value. This allows the callback to validate prompts.
+        ``full_process_value`` is removed.
+
+    .. versionchanged:: 8.0
+        ``autocompletion`` is renamed to ``shell_complete`` and has new
+        semantics described above. The old name is deprecated and will
+        be removed in 8.1, until then it will be wrapped to match the
+        new requirements.
+
+    .. versionchanged:: 8.0
+        For ``multiple=True, nargs>1``, the default must be a list of
+        tuples.
+
+    .. versionchanged:: 8.0
+        Setting a default is no longer required for ``nargs>1``, it will
+        default to ``None``. ``multiple=True`` or ``nargs=-1`` will
+        default to ``()``.
+
+    .. versionchanged:: 7.1
+        Empty environment variables are ignored rather than taking the
+        empty string value. This makes it possible for scripts to clear
+        variables if they can't unset them.
+
+    .. versionchanged:: 2.0
+        Changed signature for parameter callback to also be passed the
+        parameter. The old callback format will still work, but it will
+        raise a warning to give you a chance to migrate the code easier.
+    """
+
+    param_type_name = "parameter"
+
+    def __init__(
+        self,
+        param_decls: t.Optional[t.Sequence[str]] = None,
+        type: t.Optional[t.Union[types.ParamType, t.Any]] = None,
+        required: bool = False,
+        default: t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]] = None,
+        callback: t.Optional[t.Callable[[Context, "Parameter", t.Any], t.Any]] = None,
+        nargs: t.Optional[int] = None,
+        multiple: bool = False,
+        metavar: t.Optional[str] = None,
+        expose_value: bool = True,
+        is_eager: bool = False,
+        envvar: t.Optional[t.Union[str, t.Sequence[str]]] = None,
+        shell_complete: t.Optional[
+            t.Callable[
+                [Context, "Parameter", str],
+                t.Union[t.List["CompletionItem"], t.List[str]],
+            ]
+        ] = None,
+    ) -> None:
+        self.name: t.Optional[str]
+        self.opts: t.List[str]
+        self.secondary_opts: t.List[str]
+        self.name, self.opts, self.secondary_opts = self._parse_decls(
+            param_decls or (), expose_value
+        )
+        self.type: types.ParamType = types.convert_type(type, default)
+
+        # Default nargs to what the type tells us if we have that
+        # information available.
+        if nargs is None:
+            if self.type.is_composite:
+                nargs = self.type.arity
+            else:
+                nargs = 1
+
+        self.required = required
+        self.callback = callback
+        self.nargs = nargs
+        self.multiple = multiple
+        self.expose_value = expose_value
+        self.default = default
+        self.is_eager = is_eager
+        self.metavar = metavar
+        self.envvar = envvar
+        self._custom_shell_complete = shell_complete
+
+        if __debug__:
+            if self.type.is_composite and nargs != self.type.arity:
+                raise ValueError(
+                    f"'nargs' must be {self.type.arity} (or None) for"
+                    f" type {self.type!r}, but it was {nargs}."
+                )
+
+            # Skip no default or callable default.
+            check_default = default if not callable(default) else None
+
+            if check_default is not None:
+                if multiple:
+                    try:
+                        # Only check the first value against nargs.
+                        check_default = next(_check_iter(check_default), None)
+                    except TypeError:
+                        raise ValueError(
+                            "'default' must be a list when 'multiple' is true."
+                        ) from None
+
+                # Can be None for multiple with empty default.
+                if nargs != 1 and check_default is not None:
+                    try:
+                        _check_iter(check_default)
+                    except TypeError:
+                        if multiple:
+                            message = (
+                                "'default' must be a list of lists when 'multiple' is"
+                                " true and 'nargs' != 1."
+                            )
+                        else:
+                            message = "'default' must be a list when 'nargs' != 1."
+
+                        raise ValueError(message) from None
+
+                    if nargs > 1 and len(check_default) != nargs:
+                        subject = "item length" if multiple else "length"
+                        raise ValueError(
+                            f"'default' {subject} must match nargs={nargs}."
+                        )
+
+    def to_info_dict(self) -> t.Dict[str, t.Any]:
+        """Gather information that could be useful for a tool generating
+        user-facing documentation.
+
+        Use :meth:`click.Context.to_info_dict` to traverse the entire
+        CLI structure.
+
+        .. versionadded:: 8.0
+        """
+        return {
+            "name": self.name,
+            "param_type_name": self.param_type_name,
+            "opts": self.opts,
+            "secondary_opts": self.secondary_opts,
+            "type": self.type.to_info_dict(),
+            "required": self.required,
+            "nargs": self.nargs,
+            "multiple": self.multiple,
+            "default": self.default,
+            "envvar": self.envvar,
+        }
+
+    def __repr__(self) -> str:
+        return f"<{self.__class__.__name__} {self.name}>"
+
+    def _parse_decls(
+        self, decls: t.Sequence[str], expose_value: bool
+    ) -> t.Tuple[t.Optional[str], t.List[str], t.List[str]]:
+        raise NotImplementedError()
+
+    @property
+    def human_readable_name(self) -> str:
+        """Returns the human readable name of this parameter.  This is the
+        same as the name for options, but the metavar for arguments.
+        """
+        return self.name  # type: ignore
+
+    def make_metavar(self) -> str:
+        if self.metavar is not None:
+            return self.metavar
+
+        metavar = self.type.get_metavar(self)
+
+        if metavar is None:
+            metavar = self.type.name.upper()
+
+        if self.nargs != 1:
+            metavar += "..."
+
+        return metavar
+
+    @t.overload
+    def get_default(
+        self, ctx: Context, call: "te.Literal[True]" = True
+    ) -> t.Optional[t.Any]: ...
+
+    @t.overload
+    def get_default(
+        self, ctx: Context, call: bool = ...
+    ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: ...
+
+    def get_default(
+        self, ctx: Context, call: bool = True
+    ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]:
+        """Get the default for the parameter. Tries
+        :meth:`Context.lookup_default` first, then the local default.
+
+        :param ctx: Current context.
+        :param call: If the default is a callable, call it. Disable to
+            return the callable instead.
+
+        .. versionchanged:: 8.0.2
+            Type casting is no longer performed when getting a default.
+
+        .. versionchanged:: 8.0.1
+            Type casting can fail in resilient parsing mode. Invalid
+            defaults will not prevent showing help text.
+
+        .. versionchanged:: 8.0
+            Looks at ``ctx.default_map`` first.
+
+        .. versionchanged:: 8.0
+            Added the ``call`` parameter.
+        """
+        value = ctx.lookup_default(self.name, call=False)  # type: ignore
+
+        if value is None:
+            value = self.default
+
+        if call and callable(value):
+            value = value()
+
+        return value
+
+    def add_to_parser(self, parser: OptionParser, ctx: Context) -> None:
+        raise NotImplementedError()
+
+    def consume_value(
+        self, ctx: Context, opts: t.Mapping[str, t.Any]
+    ) -> t.Tuple[t.Any, ParameterSource]:
+        value = opts.get(self.name)  # type: ignore
+        source = ParameterSource.COMMANDLINE
+
+        if value is None:
+            value = self.value_from_envvar(ctx)
+            source = ParameterSource.ENVIRONMENT
+
+        if value is None:
+            value = ctx.lookup_default(self.name)  # type: ignore
+            source = ParameterSource.DEFAULT_MAP
+
+        if value is None:
+            value = self.get_default(ctx)
+            source = ParameterSource.DEFAULT
+
+        return value, source
+
+    def type_cast_value(self, ctx: Context, value: t.Any) -> t.Any:
+        """Convert and validate a value against the option's
+        :attr:`type`, :attr:`multiple`, and :attr:`nargs`.
+        """
+        if value is None:
+            return () if self.multiple or self.nargs == -1 else None
+
+        def check_iter(value: t.Any) -> t.Iterator[t.Any]:
+            try:
+                return _check_iter(value)
+            except TypeError:
+                # This should only happen when passing in args manually,
+                # the parser should construct an iterable when parsing
+                # the command line.
+                raise BadParameter(
+                    _("Value must be an iterable."), ctx=ctx, param=self
+                ) from None
+
+        if self.nargs == 1 or self.type.is_composite:
+
+            def convert(value: t.Any) -> t.Any:
+                return self.type(value, param=self, ctx=ctx)
+
+        elif self.nargs == -1:
+
+            def convert(value: t.Any) -> t.Any:  # t.Tuple[t.Any, ...]
+                return tuple(self.type(x, self, ctx) for x in check_iter(value))
+
+        else:  # nargs > 1
+
+            def convert(value: t.Any) -> t.Any:  # t.Tuple[t.Any, ...]
+                value = tuple(check_iter(value))
+
+                if len(value) != self.nargs:
+                    raise BadParameter(
+                        ngettext(
+                            "Takes {nargs} values but 1 was given.",
+                            "Takes {nargs} values but {len} were given.",
+                            len(value),
+                        ).format(nargs=self.nargs, len=len(value)),
+                        ctx=ctx,
+                        param=self,
+                    )
+
+                return tuple(self.type(x, self, ctx) for x in value)
+
+        if self.multiple:
+            return tuple(convert(x) for x in check_iter(value))
+
+        return convert(value)
+
+    def value_is_missing(self, value: t.Any) -> bool:
+        if value is None:
+            return True
+
+        if (self.nargs != 1 or self.multiple) and value == ():
+            return True
+
+        return False
+
+    def process_value(self, ctx: Context, value: t.Any) -> t.Any:
+        value = self.type_cast_value(ctx, value)
+
+        if self.required and self.value_is_missing(value):
+            raise MissingParameter(ctx=ctx, param=self)
+
+        if self.callback is not None:
+            value = self.callback(ctx, self, value)
+
+        return value
+
+    def resolve_envvar_value(self, ctx: Context) -> t.Optional[str]:
+        if self.envvar is None:
+            return None
+
+        if isinstance(self.envvar, str):
+            rv = os.environ.get(self.envvar)
+
+            if rv:
+                return rv
+        else:
+            for envvar in self.envvar:
+                rv = os.environ.get(envvar)
+
+                if rv:
+                    return rv
+
+        return None
+
+    def value_from_envvar(self, ctx: Context) -> t.Optional[t.Any]:
+        rv: t.Optional[t.Any] = self.resolve_envvar_value(ctx)
+
+        if rv is not None and self.nargs != 1:
+            rv = self.type.split_envvar_value(rv)
+
+        return rv
+
+    def handle_parse_result(
+        self, ctx: Context, opts: t.Mapping[str, t.Any], args: t.List[str]
+    ) -> t.Tuple[t.Any, t.List[str]]:
+        with augment_usage_errors(ctx, param=self):
+            value, source = self.consume_value(ctx, opts)
+            ctx.set_parameter_source(self.name, source)  # type: ignore
+
+            try:
+                value = self.process_value(ctx, value)
+            except Exception:
+                if not ctx.resilient_parsing:
+                    raise
+
+                value = None
+
+        if self.expose_value:
+            ctx.params[self.name] = value  # type: ignore
+
+        return value, args
+
+    def get_help_record(self, ctx: Context) -> t.Optional[t.Tuple[str, str]]:
+        pass
+
+    def get_usage_pieces(self, ctx: Context) -> t.List[str]:
+        return []
+
+    def get_error_hint(self, ctx: Context) -> str:
+        """Get a stringified version of the param for use in error messages to
+        indicate which param caused the error.
+        """
+        hint_list = self.opts or [self.human_readable_name]
+        return " / ".join(f"'{x}'" for x in hint_list)
+
+    def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]:
+        """Return a list of completions for the incomplete value. If a
+        ``shell_complete`` function was given during init, it is used.
+        Otherwise, the :attr:`type`
+        :meth:`~click.types.ParamType.shell_complete` function is used.
+
+        :param ctx: Invocation context for this command.
+        :param incomplete: Value being completed. May be empty.
+
+        .. versionadded:: 8.0
+        """
+        if self._custom_shell_complete is not None:
+            results = self._custom_shell_complete(ctx, self, incomplete)
+
+            if results and isinstance(results[0], str):
+                from click.shell_completion import CompletionItem
+
+                results = [CompletionItem(c) for c in results]
+
+            return t.cast(t.List["CompletionItem"], results)
+
+        return self.type.shell_complete(ctx, self, incomplete)
+
+
+class Option(Parameter):
+    """Options are usually optional values on the command line and
+    have some extra features that arguments don't have.
+
+    All other parameters are passed onwards to the parameter constructor.
+
+    :param show_default: Show the default value for this option in its
+        help text. Values are not shown by default, unless
+        :attr:`Context.show_default` is ``True``. If this value is a
+        string, it shows that string in parentheses instead of the
+        actual value. This is particularly useful for dynamic options.
+        For single option boolean flags, the default remains hidden if
+        its value is ``False``.
+    :param show_envvar: Controls if an environment variable should be
+        shown on the help page. Normally, environment variables are not
+        shown.
+    :param prompt: If set to ``True`` or a non empty string then the
+        user will be prompted for input. If set to ``True`` the prompt
+        will be the option name capitalized.
+    :param confirmation_prompt: Prompt a second time to confirm the
+        value if it was prompted for. Can be set to a string instead of
+        ``True`` to customize the message.
+    :param prompt_required: If set to ``False``, the user will be
+        prompted for input only when the option was specified as a flag
+        without a value.
+    :param hide_input: If this is ``True`` then the input on the prompt
+        will be hidden from the user. This is useful for password input.
+    :param is_flag: forces this option to act as a flag.  The default is
+                    auto detection.
+    :param flag_value: which value should be used for this flag if it's
+                       enabled.  This is set to a boolean automatically if
+                       the option string contains a slash to mark two options.
+    :param multiple: if this is set to `True` then the argument is accepted
+                     multiple times and recorded.  This is similar to ``nargs``
+                     in how it works but supports arbitrary number of
+                     arguments.
+    :param count: this flag makes an option increment an integer.
+    :param allow_from_autoenv: if this is enabled then the value of this
+                               parameter will be pulled from an environment
+                               variable in case a prefix is defined on the
+                               context.
+    :param help: the help string.
+    :param hidden: hide this option from help outputs.
+    :param attrs: Other command arguments described in :class:`Parameter`.
+
+    .. versionchanged:: 8.1.0
+        Help text indentation is cleaned here instead of only in the
+        ``@option`` decorator.
+
+    .. versionchanged:: 8.1.0
+        The ``show_default`` parameter overrides
+        ``Context.show_default``.
+
+    .. versionchanged:: 8.1.0
+        The default of a single option boolean flag is not shown if the
+        default value is ``False``.
+
+    .. versionchanged:: 8.0.1
+        ``type`` is detected from ``flag_value`` if given.
+    """
+
+    param_type_name = "option"
+
+    def __init__(
+        self,
+        param_decls: t.Optional[t.Sequence[str]] = None,
+        show_default: t.Union[bool, str, None] = None,
+        prompt: t.Union[bool, str] = False,
+        confirmation_prompt: t.Union[bool, str] = False,
+        prompt_required: bool = True,
+        hide_input: bool = False,
+        is_flag: t.Optional[bool] = None,
+        flag_value: t.Optional[t.Any] = None,
+        multiple: bool = False,
+        count: bool = False,
+        allow_from_autoenv: bool = True,
+        type: t.Optional[t.Union[types.ParamType, t.Any]] = None,
+        help: t.Optional[str] = None,
+        hidden: bool = False,
+        show_choices: bool = True,
+        show_envvar: bool = False,
+        **attrs: t.Any,
+    ) -> None:
+        if help:
+            help = inspect.cleandoc(help)
+
+        default_is_missing = "default" not in attrs
+        super().__init__(param_decls, type=type, multiple=multiple, **attrs)
+
+        if prompt is True:
+            if self.name is None:
+                raise TypeError("'name' is required with 'prompt=True'.")
+
+            prompt_text: t.Optional[str] = self.name.replace("_", " ").capitalize()
+        elif prompt is False:
+            prompt_text = None
+        else:
+            prompt_text = prompt
+
+        self.prompt = prompt_text
+        self.confirmation_prompt = confirmation_prompt
+        self.prompt_required = prompt_required
+        self.hide_input = hide_input
+        self.hidden = hidden
+
+        # If prompt is enabled but not required, then the option can be
+        # used as a flag to indicate using prompt or flag_value.
+        self._flag_needs_value = self.prompt is not None and not self.prompt_required
+
+        if is_flag is None:
+            if flag_value is not None:
+                # Implicitly a flag because flag_value was set.
+                is_flag = True
+            elif self._flag_needs_value:
+                # Not a flag, but when used as a flag it shows a prompt.
+                is_flag = False
+            else:
+                # Implicitly a flag because flag options were given.
+                is_flag = bool(self.secondary_opts)
+        elif is_flag is False and not self._flag_needs_value:
+            # Not a flag, and prompt is not enabled, can be used as a
+            # flag if flag_value is set.
+            self._flag_needs_value = flag_value is not None
+
+        self.default: t.Union[t.Any, t.Callable[[], t.Any]]
+
+        if is_flag and default_is_missing and not self.required:
+            if multiple:
+                self.default = ()
+            else:
+                self.default = False
+
+        if flag_value is None:
+            flag_value = not self.default
+
+        self.type: types.ParamType
+        if is_flag and type is None:
+            # Re-guess the type from the flag value instead of the
+            # default.
+            self.type = types.convert_type(None, flag_value)
+
+        self.is_flag: bool = is_flag
+        self.is_bool_flag: bool = is_flag and isinstance(self.type, types.BoolParamType)
+        self.flag_value: t.Any = flag_value
+
+        # Counting
+        self.count = count
+        if count:
+            if type is None:
+                self.type = types.IntRange(min=0)
+            if default_is_missing:
+                self.default = 0
+
+        self.allow_from_autoenv = allow_from_autoenv
+        self.help = help
+        self.show_default = show_default
+        self.show_choices = show_choices
+        self.show_envvar = show_envvar
+
+        if __debug__:
+            if self.nargs == -1:
+                raise TypeError("nargs=-1 is not supported for options.")
+
+            if self.prompt and self.is_flag and not self.is_bool_flag:
+                raise TypeError("'prompt' is not valid for non-boolean flag.")
+
+            if not self.is_bool_flag and self.secondary_opts:
+                raise TypeError("Secondary flag is not valid for non-boolean flag.")
+
+            if self.is_bool_flag and self.hide_input and self.prompt is not None:
+                raise TypeError(
+                    "'prompt' with 'hide_input' is not valid for boolean flag."
+                )
+
+            if self.count:
+                if self.multiple:
+                    raise TypeError("'count' is not valid with 'multiple'.")
+
+                if self.is_flag:
+                    raise TypeError("'count' is not valid with 'is_flag'.")
+
+    def to_info_dict(self) -> t.Dict[str, t.Any]:
+        info_dict = super().to_info_dict()
+        info_dict.update(
+            help=self.help,
+            prompt=self.prompt,
+            is_flag=self.is_flag,
+            flag_value=self.flag_value,
+            count=self.count,
+            hidden=self.hidden,
+        )
+        return info_dict
+
+    def _parse_decls(
+        self, decls: t.Sequence[str], expose_value: bool
+    ) -> t.Tuple[t.Optional[str], t.List[str], t.List[str]]:
+        opts = []
+        secondary_opts = []
+        name = None
+        possible_names = []
+
+        for decl in decls:
+            if decl.isidentifier():
+                if name is not None:
+                    raise TypeError(f"Name '{name}' defined twice")
+                name = decl
+            else:
+                split_char = ";" if decl[:1] == "/" else "/"
+                if split_char in decl:
+                    first, second = decl.split(split_char, 1)
+                    first = first.rstrip()
+                    if first:
+                        possible_names.append(split_opt(first))
+                        opts.append(first)
+                    second = second.lstrip()
+                    if second:
+                        secondary_opts.append(second.lstrip())
+                    if first == second:
+                        raise ValueError(
+                            f"Boolean option {decl!r} cannot use the"
+                            " same flag for true/false."
+                        )
+                else:
+                    possible_names.append(split_opt(decl))
+                    opts.append(decl)
+
+        if name is None and possible_names:
+            possible_names.sort(key=lambda x: -len(x[0]))  # group long options first
+            name = possible_names[0][1].replace("-", "_").lower()
+            if not name.isidentifier():
+                name = None
+
+        if name is None:
+            if not expose_value:
+                return None, opts, secondary_opts
+            raise TypeError(
+                f"Could not determine name for option with declarations {decls!r}"
+            )
+
+        if not opts and not secondary_opts:
+            raise TypeError(
+                f"No options defined but a name was passed ({name})."
+                " Did you mean to declare an argument instead? Did"
+                f" you mean to pass '--{name}'?"
+            )
+
+        return name, opts, secondary_opts
+
+    def add_to_parser(self, parser: OptionParser, ctx: Context) -> None:
+        if self.multiple:
+            action = "append"
+        elif self.count:
+            action = "count"
+        else:
+            action = "store"
+
+        if self.is_flag:
+            action = f"{action}_const"
+
+            if self.is_bool_flag and self.secondary_opts:
+                parser.add_option(
+                    obj=self, opts=self.opts, dest=self.name, action=action, const=True
+                )
+                parser.add_option(
+                    obj=self,
+                    opts=self.secondary_opts,
+                    dest=self.name,
+                    action=action,
+                    const=False,
+                )
+            else:
+                parser.add_option(
+                    obj=self,
+                    opts=self.opts,
+                    dest=self.name,
+                    action=action,
+                    const=self.flag_value,
+                )
+        else:
+            parser.add_option(
+                obj=self,
+                opts=self.opts,
+                dest=self.name,
+                action=action,
+                nargs=self.nargs,
+            )
+
+    def get_help_record(self, ctx: Context) -> t.Optional[t.Tuple[str, str]]:
+        if self.hidden:
+            return None
+
+        any_prefix_is_slash = False
+
+        def _write_opts(opts: t.Sequence[str]) -> str:
+            nonlocal any_prefix_is_slash
+
+            rv, any_slashes = join_options(opts)
+
+            if any_slashes:
+                any_prefix_is_slash = True
+
+            if not self.is_flag and not self.count:
+                rv += f" {self.make_metavar()}"
+
+            return rv
+
+        rv = [_write_opts(self.opts)]
+
+        if self.secondary_opts:
+            rv.append(_write_opts(self.secondary_opts))
+
+        help = self.help or ""
+        extra = []
+
+        if self.show_envvar:
+            envvar = self.envvar
+
+            if envvar is None:
+                if (
+                    self.allow_from_autoenv
+                    and ctx.auto_envvar_prefix is not None
+                    and self.name is not None
+                ):
+                    envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}"
+
+            if envvar is not None:
+                var_str = (
+                    envvar
+                    if isinstance(envvar, str)
+                    else ", ".join(str(d) for d in envvar)
+                )
+                extra.append(_("env var: {var}").format(var=var_str))
+
+        # Temporarily enable resilient parsing to avoid type casting
+        # failing for the default. Might be possible to extend this to
+        # help formatting in general.
+        resilient = ctx.resilient_parsing
+        ctx.resilient_parsing = True
+
+        try:
+            default_value = self.get_default(ctx, call=False)
+        finally:
+            ctx.resilient_parsing = resilient
+
+        show_default = False
+        show_default_is_str = False
+
+        if self.show_default is not None:
+            if isinstance(self.show_default, str):
+                show_default_is_str = show_default = True
+            else:
+                show_default = self.show_default
+        elif ctx.show_default is not None:
+            show_default = ctx.show_default
+
+        if show_default_is_str or (show_default and (default_value is not None)):
+            if show_default_is_str:
+                default_string = f"({self.show_default})"
+            elif isinstance(default_value, (list, tuple)):
+                default_string = ", ".join(str(d) for d in default_value)
+            elif inspect.isfunction(default_value):
+                default_string = _("(dynamic)")
+            elif self.is_bool_flag and self.secondary_opts:
+                # For boolean flags that have distinct True/False opts,
+                # use the opt without prefix instead of the value.
+                default_string = split_opt(
+                    (self.opts if default_value else self.secondary_opts)[0]
+                )[1]
+            elif self.is_bool_flag and not self.secondary_opts and not default_value:
+                default_string = ""
+            elif default_value == "":
+                default_string = '""'
+            else:
+                default_string = str(default_value)
+
+            if default_string:
+                extra.append(_("default: {default}").format(default=default_string))
+
+        if (
+            isinstance(self.type, types._NumberRangeBase)
+            # skip count with default range type
+            and not (self.count and self.type.min == 0 and self.type.max is None)
+        ):
+            range_str = self.type._describe_range()
+
+            if range_str:
+                extra.append(range_str)
+
+        if self.required:
+            extra.append(_("required"))
+
+        if extra:
+            extra_str = "; ".join(extra)
+            help = f"{help}  [{extra_str}]" if help else f"[{extra_str}]"
+
+        return ("; " if any_prefix_is_slash else " / ").join(rv), help
+
+    @t.overload
+    def get_default(
+        self, ctx: Context, call: "te.Literal[True]" = True
+    ) -> t.Optional[t.Any]: ...
+
+    @t.overload
+    def get_default(
+        self, ctx: Context, call: bool = ...
+    ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: ...
+
+    def get_default(
+        self, ctx: Context, call: bool = True
+    ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]:
+        # If we're a non boolean flag our default is more complex because
+        # we need to look at all flags in the same group to figure out
+        # if we're the default one in which case we return the flag
+        # value as default.
+        if self.is_flag and not self.is_bool_flag:
+            for param in ctx.command.params:
+                if param.name == self.name and param.default:
+                    return t.cast(Option, param).flag_value
+
+            return None
+
+        return super().get_default(ctx, call=call)
+
+    def prompt_for_value(self, ctx: Context) -> t.Any:
+        """This is an alternative flow that can be activated in the full
+        value processing if a value does not exist.  It will prompt the
+        user until a valid value exists and then returns the processed
+        value as result.
+        """
+        assert self.prompt is not None
+
+        # Calculate the default before prompting anything to be stable.
+        default = self.get_default(ctx)
+
+        # If this is a prompt for a flag we need to handle this
+        # differently.
+        if self.is_bool_flag:
+            return confirm(self.prompt, default)
+
+        return prompt(
+            self.prompt,
+            default=default,
+            type=self.type,
+            hide_input=self.hide_input,
+            show_choices=self.show_choices,
+            confirmation_prompt=self.confirmation_prompt,
+            value_proc=lambda x: self.process_value(ctx, x),
+        )
+
+    def resolve_envvar_value(self, ctx: Context) -> t.Optional[str]:
+        rv = super().resolve_envvar_value(ctx)
+
+        if rv is not None:
+            return rv
+
+        if (
+            self.allow_from_autoenv
+            and ctx.auto_envvar_prefix is not None
+            and self.name is not None
+        ):
+            envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}"
+            rv = os.environ.get(envvar)
+
+            if rv:
+                return rv
+
+        return None
+
+    def value_from_envvar(self, ctx: Context) -> t.Optional[t.Any]:
+        rv: t.Optional[t.Any] = self.resolve_envvar_value(ctx)
+
+        if rv is None:
+            return None
+
+        value_depth = (self.nargs != 1) + bool(self.multiple)
+
+        if value_depth > 0:
+            rv = self.type.split_envvar_value(rv)
+
+            if self.multiple and self.nargs != 1:
+                rv = batch(rv, self.nargs)
+
+        return rv
+
+    def consume_value(
+        self, ctx: Context, opts: t.Mapping[str, "Parameter"]
+    ) -> t.Tuple[t.Any, ParameterSource]:
+        value, source = super().consume_value(ctx, opts)
+
+        # The parser will emit a sentinel value if the option can be
+        # given as a flag without a value. This is different from None
+        # to distinguish from the flag not being given at all.
+        if value is _flag_needs_value:
+            if self.prompt is not None and not ctx.resilient_parsing:
+                value = self.prompt_for_value(ctx)
+                source = ParameterSource.PROMPT
+            else:
+                value = self.flag_value
+                source = ParameterSource.COMMANDLINE
+
+        elif (
+            self.multiple
+            and value is not None
+            and any(v is _flag_needs_value for v in value)
+        ):
+            value = [self.flag_value if v is _flag_needs_value else v for v in value]
+            source = ParameterSource.COMMANDLINE
+
+        # The value wasn't set, or used the param's default, prompt if
+        # prompting is enabled.
+        elif (
+            source in {None, ParameterSource.DEFAULT}
+            and self.prompt is not None
+            and (self.required or self.prompt_required)
+            and not ctx.resilient_parsing
+        ):
+            value = self.prompt_for_value(ctx)
+            source = ParameterSource.PROMPT
+
+        return value, source
+
+
+class Argument(Parameter):
+    """Arguments are positional parameters to a command.  They generally
+    provide fewer features than options but can have infinite ``nargs``
+    and are required by default.
+
+    All parameters are passed onwards to the constructor of :class:`Parameter`.
+    """
+
+    param_type_name = "argument"
+
+    def __init__(
+        self,
+        param_decls: t.Sequence[str],
+        required: t.Optional[bool] = None,
+        **attrs: t.Any,
+    ) -> None:
+        if required is None:
+            if attrs.get("default") is not None:
+                required = False
+            else:
+                required = attrs.get("nargs", 1) > 0
+
+        if "multiple" in attrs:
+            raise TypeError("__init__() got an unexpected keyword argument 'multiple'.")
+
+        super().__init__(param_decls, required=required, **attrs)
+
+        if __debug__:
+            if self.default is not None and self.nargs == -1:
+                raise TypeError("'default' is not supported for nargs=-1.")
+
+    @property
+    def human_readable_name(self) -> str:
+        if self.metavar is not None:
+            return self.metavar
+        return self.name.upper()  # type: ignore
+
+    def make_metavar(self) -> str:
+        if self.metavar is not None:
+            return self.metavar
+        var = self.type.get_metavar(self)
+        if not var:
+            var = self.name.upper()  # type: ignore
+        if not self.required:
+            var = f"[{var}]"
+        if self.nargs != 1:
+            var += "..."
+        return var
+
+    def _parse_decls(
+        self, decls: t.Sequence[str], expose_value: bool
+    ) -> t.Tuple[t.Optional[str], t.List[str], t.List[str]]:
+        if not decls:
+            if not expose_value:
+                return None, [], []
+            raise TypeError("Argument is marked as exposed, but does not have a name.")
+        if len(decls) == 1:
+            name = arg = decls[0]
+            name = name.replace("-", "_").lower()
+        else:
+            raise TypeError(
+                "Arguments take exactly one parameter declaration, got"
+                f" {len(decls)}."
+            )
+        return name, [arg], []
+
+    def get_usage_pieces(self, ctx: Context) -> t.List[str]:
+        return [self.make_metavar()]
+
+    def get_error_hint(self, ctx: Context) -> str:
+        return f"'{self.make_metavar()}'"
+
+    def add_to_parser(self, parser: OptionParser, ctx: Context) -> None:
+        parser.add_argument(dest=self.name, nargs=self.nargs, obj=self)

+ 562 - 0
venv/lib/python3.9/site-packages/click/decorators.py

@@ -0,0 +1,562 @@
+import inspect
+import types
+import typing as t
+from functools import update_wrapper
+from gettext import gettext as _
+
+from .core import Argument
+from .core import Command
+from .core import Context
+from .core import Group
+from .core import Option
+from .core import Parameter
+from .globals import get_current_context
+from .utils import echo
+
+if t.TYPE_CHECKING:
+    import typing_extensions as te
+
+    P = te.ParamSpec("P")
+
+R = t.TypeVar("R")
+T = t.TypeVar("T")
+_AnyCallable = t.Callable[..., t.Any]
+FC = t.TypeVar("FC", bound=t.Union[_AnyCallable, Command])
+
+
+def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]":
+    """Marks a callback as wanting to receive the current context
+    object as first argument.
+    """
+
+    def new_func(*args: "P.args", **kwargs: "P.kwargs") -> "R":
+        return f(get_current_context(), *args, **kwargs)
+
+    return update_wrapper(new_func, f)
+
+
+def pass_obj(f: "t.Callable[te.Concatenate[t.Any, P], R]") -> "t.Callable[P, R]":
+    """Similar to :func:`pass_context`, but only pass the object on the
+    context onwards (:attr:`Context.obj`).  This is useful if that object
+    represents the state of a nested system.
+    """
+
+    def new_func(*args: "P.args", **kwargs: "P.kwargs") -> "R":
+        return f(get_current_context().obj, *args, **kwargs)
+
+    return update_wrapper(new_func, f)
+
+
+def make_pass_decorator(
+    object_type: t.Type[T], ensure: bool = False
+) -> t.Callable[["t.Callable[te.Concatenate[T, P], R]"], "t.Callable[P, R]"]:
+    """Given an object type this creates a decorator that will work
+    similar to :func:`pass_obj` but instead of passing the object of the
+    current context, it will find the innermost context of type
+    :func:`object_type`.
+
+    This generates a decorator that works roughly like this::
+
+        from functools import update_wrapper
+
+        def decorator(f):
+            @pass_context
+            def new_func(ctx, *args, **kwargs):
+                obj = ctx.find_object(object_type)
+                return ctx.invoke(f, obj, *args, **kwargs)
+            return update_wrapper(new_func, f)
+        return decorator
+
+    :param object_type: the type of the object to pass.
+    :param ensure: if set to `True`, a new object will be created and
+                   remembered on the context if it's not there yet.
+    """
+
+    def decorator(f: "t.Callable[te.Concatenate[T, P], R]") -> "t.Callable[P, R]":
+        def new_func(*args: "P.args", **kwargs: "P.kwargs") -> "R":
+            ctx = get_current_context()
+
+            obj: t.Optional[T]
+            if ensure:
+                obj = ctx.ensure_object(object_type)
+            else:
+                obj = ctx.find_object(object_type)
+
+            if obj is None:
+                raise RuntimeError(
+                    "Managed to invoke callback without a context"
+                    f" object of type {object_type.__name__!r}"
+                    " existing."
+                )
+
+            return ctx.invoke(f, obj, *args, **kwargs)
+
+        return update_wrapper(new_func, f)
+
+    return decorator
+
+
+def pass_meta_key(
+    key: str, *, doc_description: t.Optional[str] = None
+) -> "t.Callable[[t.Callable[te.Concatenate[t.Any, P], R]], t.Callable[P, R]]":
+    """Create a decorator that passes a key from
+    :attr:`click.Context.meta` as the first argument to the decorated
+    function.
+
+    :param key: Key in ``Context.meta`` to pass.
+    :param doc_description: Description of the object being passed,
+        inserted into the decorator's docstring. Defaults to "the 'key'
+        key from Context.meta".
+
+    .. versionadded:: 8.0
+    """
+
+    def decorator(f: "t.Callable[te.Concatenate[t.Any, P], R]") -> "t.Callable[P, R]":
+        def new_func(*args: "P.args", **kwargs: "P.kwargs") -> R:
+            ctx = get_current_context()
+            obj = ctx.meta[key]
+            return ctx.invoke(f, obj, *args, **kwargs)
+
+        return update_wrapper(new_func, f)
+
+    if doc_description is None:
+        doc_description = f"the {key!r} key from :attr:`click.Context.meta`"
+
+    decorator.__doc__ = (
+        f"Decorator that passes {doc_description} as the first argument"
+        " to the decorated function."
+    )
+    return decorator
+
+
+CmdType = t.TypeVar("CmdType", bound=Command)
+
+
+# variant: no call, directly as decorator for a function.
+@t.overload
+def command(name: _AnyCallable) -> Command: ...
+
+
+# variant: with positional name and with positional or keyword cls argument:
+# @command(namearg, CommandCls, ...) or @command(namearg, cls=CommandCls, ...)
+@t.overload
+def command(
+    name: t.Optional[str],
+    cls: t.Type[CmdType],
+    **attrs: t.Any,
+) -> t.Callable[[_AnyCallable], CmdType]: ...
+
+
+# variant: name omitted, cls _must_ be a keyword argument, @command(cls=CommandCls, ...)
+@t.overload
+def command(
+    name: None = None,
+    *,
+    cls: t.Type[CmdType],
+    **attrs: t.Any,
+) -> t.Callable[[_AnyCallable], CmdType]: ...
+
+
+# variant: with optional string name, no cls argument provided.
+@t.overload
+def command(
+    name: t.Optional[str] = ..., cls: None = None, **attrs: t.Any
+) -> t.Callable[[_AnyCallable], Command]: ...
+
+
+def command(
+    name: t.Union[t.Optional[str], _AnyCallable] = None,
+    cls: t.Optional[t.Type[CmdType]] = None,
+    **attrs: t.Any,
+) -> t.Union[Command, t.Callable[[_AnyCallable], t.Union[Command, CmdType]]]:
+    r"""Creates a new :class:`Command` and uses the decorated function as
+    callback.  This will also automatically attach all decorated
+    :func:`option`\s and :func:`argument`\s as parameters to the command.
+
+    The name of the command defaults to the name of the function with
+    underscores replaced by dashes.  If you want to change that, you can
+    pass the intended name as the first argument.
+
+    All keyword arguments are forwarded to the underlying command class.
+    For the ``params`` argument, any decorated params are appended to
+    the end of the list.
+
+    Once decorated the function turns into a :class:`Command` instance
+    that can be invoked as a command line utility or be attached to a
+    command :class:`Group`.
+
+    :param name: the name of the command.  This defaults to the function
+                 name with underscores replaced by dashes.
+    :param cls: the command class to instantiate.  This defaults to
+                :class:`Command`.
+
+    .. versionchanged:: 8.1
+        This decorator can be applied without parentheses.
+
+    .. versionchanged:: 8.1
+        The ``params`` argument can be used. Decorated params are
+        appended to the end of the list.
+    """
+
+    func: t.Optional[t.Callable[[_AnyCallable], t.Any]] = None
+
+    if callable(name):
+        func = name
+        name = None
+        assert cls is None, "Use 'command(cls=cls)(callable)' to specify a class."
+        assert not attrs, "Use 'command(**kwargs)(callable)' to provide arguments."
+
+    if cls is None:
+        cls = t.cast(t.Type[CmdType], Command)
+
+    def decorator(f: _AnyCallable) -> CmdType:
+        if isinstance(f, Command):
+            raise TypeError("Attempted to convert a callback into a command twice.")
+
+        attr_params = attrs.pop("params", None)
+        params = attr_params if attr_params is not None else []
+
+        try:
+            decorator_params = f.__click_params__  # type: ignore
+        except AttributeError:
+            pass
+        else:
+            del f.__click_params__  # type: ignore
+            params.extend(reversed(decorator_params))
+
+        if attrs.get("help") is None:
+            attrs["help"] = f.__doc__
+
+        if t.TYPE_CHECKING:
+            assert cls is not None
+            assert not callable(name)
+
+        cmd = cls(
+            name=name or f.__name__.lower().replace("_", "-"),
+            callback=f,
+            params=params,
+            **attrs,
+        )
+        cmd.__doc__ = f.__doc__
+        return cmd
+
+    if func is not None:
+        return decorator(func)
+
+    return decorator
+
+
+GrpType = t.TypeVar("GrpType", bound=Group)
+
+
+# variant: no call, directly as decorator for a function.
+@t.overload
+def group(name: _AnyCallable) -> Group: ...
+
+
+# variant: with positional name and with positional or keyword cls argument:
+# @group(namearg, GroupCls, ...) or @group(namearg, cls=GroupCls, ...)
+@t.overload
+def group(
+    name: t.Optional[str],
+    cls: t.Type[GrpType],
+    **attrs: t.Any,
+) -> t.Callable[[_AnyCallable], GrpType]: ...
+
+
+# variant: name omitted, cls _must_ be a keyword argument, @group(cmd=GroupCls, ...)
+@t.overload
+def group(
+    name: None = None,
+    *,
+    cls: t.Type[GrpType],
+    **attrs: t.Any,
+) -> t.Callable[[_AnyCallable], GrpType]: ...
+
+
+# variant: with optional string name, no cls argument provided.
+@t.overload
+def group(
+    name: t.Optional[str] = ..., cls: None = None, **attrs: t.Any
+) -> t.Callable[[_AnyCallable], Group]: ...
+
+
+def group(
+    name: t.Union[str, _AnyCallable, None] = None,
+    cls: t.Optional[t.Type[GrpType]] = None,
+    **attrs: t.Any,
+) -> t.Union[Group, t.Callable[[_AnyCallable], t.Union[Group, GrpType]]]:
+    """Creates a new :class:`Group` with a function as callback.  This
+    works otherwise the same as :func:`command` just that the `cls`
+    parameter is set to :class:`Group`.
+
+    .. versionchanged:: 8.1
+        This decorator can be applied without parentheses.
+    """
+    if cls is None:
+        cls = t.cast(t.Type[GrpType], Group)
+
+    if callable(name):
+        return command(cls=cls, **attrs)(name)
+
+    return command(name, cls, **attrs)
+
+
+def _param_memo(f: t.Callable[..., t.Any], param: Parameter) -> None:
+    if isinstance(f, Command):
+        f.params.append(param)
+    else:
+        if not hasattr(f, "__click_params__"):
+            f.__click_params__ = []  # type: ignore
+
+        f.__click_params__.append(param)  # type: ignore
+
+
+def argument(
+    *param_decls: str, cls: t.Optional[t.Type[Argument]] = None, **attrs: t.Any
+) -> t.Callable[[FC], FC]:
+    """Attaches an argument to the command.  All positional arguments are
+    passed as parameter declarations to :class:`Argument`; all keyword
+    arguments are forwarded unchanged (except ``cls``).
+    This is equivalent to creating an :class:`Argument` instance manually
+    and attaching it to the :attr:`Command.params` list.
+
+    For the default argument class, refer to :class:`Argument` and
+    :class:`Parameter` for descriptions of parameters.
+
+    :param cls: the argument class to instantiate.  This defaults to
+                :class:`Argument`.
+    :param param_decls: Passed as positional arguments to the constructor of
+        ``cls``.
+    :param attrs: Passed as keyword arguments to the constructor of ``cls``.
+    """
+    if cls is None:
+        cls = Argument
+
+    def decorator(f: FC) -> FC:
+        _param_memo(f, cls(param_decls, **attrs))
+        return f
+
+    return decorator
+
+
+def option(
+    *param_decls: str, cls: t.Optional[t.Type[Option]] = None, **attrs: t.Any
+) -> t.Callable[[FC], FC]:
+    """Attaches an option to the command.  All positional arguments are
+    passed as parameter declarations to :class:`Option`; all keyword
+    arguments are forwarded unchanged (except ``cls``).
+    This is equivalent to creating an :class:`Option` instance manually
+    and attaching it to the :attr:`Command.params` list.
+
+    For the default option class, refer to :class:`Option` and
+    :class:`Parameter` for descriptions of parameters.
+
+    :param cls: the option class to instantiate.  This defaults to
+                :class:`Option`.
+    :param param_decls: Passed as positional arguments to the constructor of
+        ``cls``.
+    :param attrs: Passed as keyword arguments to the constructor of ``cls``.
+    """
+    if cls is None:
+        cls = Option
+
+    def decorator(f: FC) -> FC:
+        _param_memo(f, cls(param_decls, **attrs))
+        return f
+
+    return decorator
+
+
+def confirmation_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]:
+    """Add a ``--yes`` option which shows a prompt before continuing if
+    not passed. If the prompt is declined, the program will exit.
+
+    :param param_decls: One or more option names. Defaults to the single
+        value ``"--yes"``.
+    :param kwargs: Extra arguments are passed to :func:`option`.
+    """
+
+    def callback(ctx: Context, param: Parameter, value: bool) -> None:
+        if not value:
+            ctx.abort()
+
+    if not param_decls:
+        param_decls = ("--yes",)
+
+    kwargs.setdefault("is_flag", True)
+    kwargs.setdefault("callback", callback)
+    kwargs.setdefault("expose_value", False)
+    kwargs.setdefault("prompt", "Do you want to continue?")
+    kwargs.setdefault("help", "Confirm the action without prompting.")
+    return option(*param_decls, **kwargs)
+
+
+def password_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]:
+    """Add a ``--password`` option which prompts for a password, hiding
+    input and asking to enter the value again for confirmation.
+
+    :param param_decls: One or more option names. Defaults to the single
+        value ``"--password"``.
+    :param kwargs: Extra arguments are passed to :func:`option`.
+    """
+    if not param_decls:
+        param_decls = ("--password",)
+
+    kwargs.setdefault("prompt", True)
+    kwargs.setdefault("confirmation_prompt", True)
+    kwargs.setdefault("hide_input", True)
+    return option(*param_decls, **kwargs)
+
+
+def version_option(
+    version: t.Optional[str] = None,
+    *param_decls: str,
+    package_name: t.Optional[str] = None,
+    prog_name: t.Optional[str] = None,
+    message: t.Optional[str] = None,
+    **kwargs: t.Any,
+) -> t.Callable[[FC], FC]:
+    """Add a ``--version`` option which immediately prints the version
+    number and exits the program.
+
+    If ``version`` is not provided, Click will try to detect it using
+    :func:`importlib.metadata.version` to get the version for the
+    ``package_name``. On Python < 3.8, the ``importlib_metadata``
+    backport must be installed.
+
+    If ``package_name`` is not provided, Click will try to detect it by
+    inspecting the stack frames. This will be used to detect the
+    version, so it must match the name of the installed package.
+
+    :param version: The version number to show. If not provided, Click
+        will try to detect it.
+    :param param_decls: One or more option names. Defaults to the single
+        value ``"--version"``.
+    :param package_name: The package name to detect the version from. If
+        not provided, Click will try to detect it.
+    :param prog_name: The name of the CLI to show in the message. If not
+        provided, it will be detected from the command.
+    :param message: The message to show. The values ``%(prog)s``,
+        ``%(package)s``, and ``%(version)s`` are available. Defaults to
+        ``"%(prog)s, version %(version)s"``.
+    :param kwargs: Extra arguments are passed to :func:`option`.
+    :raise RuntimeError: ``version`` could not be detected.
+
+    .. versionchanged:: 8.0
+        Add the ``package_name`` parameter, and the ``%(package)s``
+        value for messages.
+
+    .. versionchanged:: 8.0
+        Use :mod:`importlib.metadata` instead of ``pkg_resources``. The
+        version is detected based on the package name, not the entry
+        point name. The Python package name must match the installed
+        package name, or be passed with ``package_name=``.
+    """
+    if message is None:
+        message = _("%(prog)s, version %(version)s")
+
+    if version is None and package_name is None:
+        frame = inspect.currentframe()
+        f_back = frame.f_back if frame is not None else None
+        f_globals = f_back.f_globals if f_back is not None else None
+        # break reference cycle
+        # https://docs.python.org/3/library/inspect.html#the-interpreter-stack
+        del frame
+
+        if f_globals is not None:
+            package_name = f_globals.get("__name__")
+
+            if package_name == "__main__":
+                package_name = f_globals.get("__package__")
+
+            if package_name:
+                package_name = package_name.partition(".")[0]
+
+    def callback(ctx: Context, param: Parameter, value: bool) -> None:
+        if not value or ctx.resilient_parsing:
+            return
+
+        nonlocal prog_name
+        nonlocal version
+
+        if prog_name is None:
+            prog_name = ctx.find_root().info_name
+
+        if version is None and package_name is not None:
+            metadata: t.Optional[types.ModuleType]
+
+            try:
+                from importlib import metadata
+            except ImportError:
+                # Python < 3.8
+                import importlib_metadata as metadata  # type: ignore
+
+            try:
+                version = metadata.version(package_name)  # type: ignore
+            except metadata.PackageNotFoundError:  # type: ignore
+                raise RuntimeError(
+                    f"{package_name!r} is not installed. Try passing"
+                    " 'package_name' instead."
+                ) from None
+
+        if version is None:
+            raise RuntimeError(
+                f"Could not determine the version for {package_name!r} automatically."
+            )
+
+        echo(
+            message % {"prog": prog_name, "package": package_name, "version": version},
+            color=ctx.color,
+        )
+        ctx.exit()
+
+    if not param_decls:
+        param_decls = ("--version",)
+
+    kwargs.setdefault("is_flag", True)
+    kwargs.setdefault("expose_value", False)
+    kwargs.setdefault("is_eager", True)
+    kwargs.setdefault("help", _("Show the version and exit."))
+    kwargs["callback"] = callback
+    return option(*param_decls, **kwargs)
+
+
+class HelpOption(Option):
+    """Pre-configured ``--help`` option which immediately prints the help page
+    and exits the program.
+    """
+
+    def __init__(
+        self,
+        param_decls: t.Optional[t.Sequence[str]] = None,
+        **kwargs: t.Any,
+    ) -> None:
+        if not param_decls:
+            param_decls = ("--help",)
+
+        kwargs.setdefault("is_flag", True)
+        kwargs.setdefault("expose_value", False)
+        kwargs.setdefault("is_eager", True)
+        kwargs.setdefault("help", _("Show this message and exit."))
+        kwargs.setdefault("callback", self.show_help)
+
+        super().__init__(param_decls, **kwargs)
+
+    @staticmethod
+    def show_help(ctx: Context, param: Parameter, value: bool) -> None:
+        """Callback that print the help page on ``<stdout>`` and exits."""
+        if value and not ctx.resilient_parsing:
+            echo(ctx.get_help(), color=ctx.color)
+            ctx.exit()
+
+
+def help_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]:
+    """Decorator for the pre-configured ``--help`` option defined above.
+
+    :param param_decls: One or more option names. Defaults to the single
+        value ``"--help"``.
+    :param kwargs: Extra arguments are passed to :func:`option`.
+    """
+    kwargs.setdefault("cls", HelpOption)
+    return option(*param_decls, **kwargs)

+ 296 - 0
venv/lib/python3.9/site-packages/click/exceptions.py

@@ -0,0 +1,296 @@
+import typing as t
+from gettext import gettext as _
+from gettext import ngettext
+
+from ._compat import get_text_stderr
+from .globals import resolve_color_default
+from .utils import echo
+from .utils import format_filename
+
+if t.TYPE_CHECKING:
+    from .core import Command
+    from .core import Context
+    from .core import Parameter
+
+
+def _join_param_hints(
+    param_hint: t.Optional[t.Union[t.Sequence[str], str]],
+) -> t.Optional[str]:
+    if param_hint is not None and not isinstance(param_hint, str):
+        return " / ".join(repr(x) for x in param_hint)
+
+    return param_hint
+
+
+class ClickException(Exception):
+    """An exception that Click can handle and show to the user."""
+
+    #: The exit code for this exception.
+    exit_code = 1
+
+    def __init__(self, message: str) -> None:
+        super().__init__(message)
+        # The context will be removed by the time we print the message, so cache
+        # the color settings here to be used later on (in `show`)
+        self.show_color: t.Optional[bool] = resolve_color_default()
+        self.message = message
+
+    def format_message(self) -> str:
+        return self.message
+
+    def __str__(self) -> str:
+        return self.message
+
+    def show(self, file: t.Optional[t.IO[t.Any]] = None) -> None:
+        if file is None:
+            file = get_text_stderr()
+
+        echo(
+            _("Error: {message}").format(message=self.format_message()),
+            file=file,
+            color=self.show_color,
+        )
+
+
+class UsageError(ClickException):
+    """An internal exception that signals a usage error.  This typically
+    aborts any further handling.
+
+    :param message: the error message to display.
+    :param ctx: optionally the context that caused this error.  Click will
+                fill in the context automatically in some situations.
+    """
+
+    exit_code = 2
+
+    def __init__(self, message: str, ctx: t.Optional["Context"] = None) -> None:
+        super().__init__(message)
+        self.ctx = ctx
+        self.cmd: t.Optional[Command] = self.ctx.command if self.ctx else None
+
+    def show(self, file: t.Optional[t.IO[t.Any]] = None) -> None:
+        if file is None:
+            file = get_text_stderr()
+        color = None
+        hint = ""
+        if (
+            self.ctx is not None
+            and self.ctx.command.get_help_option(self.ctx) is not None
+        ):
+            hint = _("Try '{command} {option}' for help.").format(
+                command=self.ctx.command_path, option=self.ctx.help_option_names[0]
+            )
+            hint = f"{hint}\n"
+        if self.ctx is not None:
+            color = self.ctx.color
+            echo(f"{self.ctx.get_usage()}\n{hint}", file=file, color=color)
+        echo(
+            _("Error: {message}").format(message=self.format_message()),
+            file=file,
+            color=color,
+        )
+
+
+class BadParameter(UsageError):
+    """An exception that formats out a standardized error message for a
+    bad parameter.  This is useful when thrown from a callback or type as
+    Click will attach contextual information to it (for instance, which
+    parameter it is).
+
+    .. versionadded:: 2.0
+
+    :param param: the parameter object that caused this error.  This can
+                  be left out, and Click will attach this info itself
+                  if possible.
+    :param param_hint: a string that shows up as parameter name.  This
+                       can be used as alternative to `param` in cases
+                       where custom validation should happen.  If it is
+                       a string it's used as such, if it's a list then
+                       each item is quoted and separated.
+    """
+
+    def __init__(
+        self,
+        message: str,
+        ctx: t.Optional["Context"] = None,
+        param: t.Optional["Parameter"] = None,
+        param_hint: t.Optional[str] = None,
+    ) -> None:
+        super().__init__(message, ctx)
+        self.param = param
+        self.param_hint = param_hint
+
+    def format_message(self) -> str:
+        if self.param_hint is not None:
+            param_hint = self.param_hint
+        elif self.param is not None:
+            param_hint = self.param.get_error_hint(self.ctx)  # type: ignore
+        else:
+            return _("Invalid value: {message}").format(message=self.message)
+
+        return _("Invalid value for {param_hint}: {message}").format(
+            param_hint=_join_param_hints(param_hint), message=self.message
+        )
+
+
+class MissingParameter(BadParameter):
+    """Raised if click required an option or argument but it was not
+    provided when invoking the script.
+
+    .. versionadded:: 4.0
+
+    :param param_type: a string that indicates the type of the parameter.
+                       The default is to inherit the parameter type from
+                       the given `param`.  Valid values are ``'parameter'``,
+                       ``'option'`` or ``'argument'``.
+    """
+
+    def __init__(
+        self,
+        message: t.Optional[str] = None,
+        ctx: t.Optional["Context"] = None,
+        param: t.Optional["Parameter"] = None,
+        param_hint: t.Optional[str] = None,
+        param_type: t.Optional[str] = None,
+    ) -> None:
+        super().__init__(message or "", ctx, param, param_hint)
+        self.param_type = param_type
+
+    def format_message(self) -> str:
+        if self.param_hint is not None:
+            param_hint: t.Optional[str] = self.param_hint
+        elif self.param is not None:
+            param_hint = self.param.get_error_hint(self.ctx)  # type: ignore
+        else:
+            param_hint = None
+
+        param_hint = _join_param_hints(param_hint)
+        param_hint = f" {param_hint}" if param_hint else ""
+
+        param_type = self.param_type
+        if param_type is None and self.param is not None:
+            param_type = self.param.param_type_name
+
+        msg = self.message
+        if self.param is not None:
+            msg_extra = self.param.type.get_missing_message(self.param)
+            if msg_extra:
+                if msg:
+                    msg += f". {msg_extra}"
+                else:
+                    msg = msg_extra
+
+        msg = f" {msg}" if msg else ""
+
+        # Translate param_type for known types.
+        if param_type == "argument":
+            missing = _("Missing argument")
+        elif param_type == "option":
+            missing = _("Missing option")
+        elif param_type == "parameter":
+            missing = _("Missing parameter")
+        else:
+            missing = _("Missing {param_type}").format(param_type=param_type)
+
+        return f"{missing}{param_hint}.{msg}"
+
+    def __str__(self) -> str:
+        if not self.message:
+            param_name = self.param.name if self.param else None
+            return _("Missing parameter: {param_name}").format(param_name=param_name)
+        else:
+            return self.message
+
+
+class NoSuchOption(UsageError):
+    """Raised if click attempted to handle an option that does not
+    exist.
+
+    .. versionadded:: 4.0
+    """
+
+    def __init__(
+        self,
+        option_name: str,
+        message: t.Optional[str] = None,
+        possibilities: t.Optional[t.Sequence[str]] = None,
+        ctx: t.Optional["Context"] = None,
+    ) -> None:
+        if message is None:
+            message = _("No such option: {name}").format(name=option_name)
+
+        super().__init__(message, ctx)
+        self.option_name = option_name
+        self.possibilities = possibilities
+
+    def format_message(self) -> str:
+        if not self.possibilities:
+            return self.message
+
+        possibility_str = ", ".join(sorted(self.possibilities))
+        suggest = ngettext(
+            "Did you mean {possibility}?",
+            "(Possible options: {possibilities})",
+            len(self.possibilities),
+        ).format(possibility=possibility_str, possibilities=possibility_str)
+        return f"{self.message} {suggest}"
+
+
+class BadOptionUsage(UsageError):
+    """Raised if an option is generally supplied but the use of the option
+    was incorrect.  This is for instance raised if the number of arguments
+    for an option is not correct.
+
+    .. versionadded:: 4.0
+
+    :param option_name: the name of the option being used incorrectly.
+    """
+
+    def __init__(
+        self, option_name: str, message: str, ctx: t.Optional["Context"] = None
+    ) -> None:
+        super().__init__(message, ctx)
+        self.option_name = option_name
+
+
+class BadArgumentUsage(UsageError):
+    """Raised if an argument is generally supplied but the use of the argument
+    was incorrect.  This is for instance raised if the number of values
+    for an argument is not correct.
+
+    .. versionadded:: 6.0
+    """
+
+
+class FileError(ClickException):
+    """Raised if a file cannot be opened."""
+
+    def __init__(self, filename: str, hint: t.Optional[str] = None) -> None:
+        if hint is None:
+            hint = _("unknown error")
+
+        super().__init__(hint)
+        self.ui_filename: str = format_filename(filename)
+        self.filename = filename
+
+    def format_message(self) -> str:
+        return _("Could not open file {filename!r}: {message}").format(
+            filename=self.ui_filename, message=self.message
+        )
+
+
+class Abort(RuntimeError):
+    """An internal signalling exception that signals Click to abort."""
+
+
+class Exit(RuntimeError):
+    """An exception that indicates that the application should exit with some
+    status code.
+
+    :param code: the status code to exit with.
+    """
+
+    __slots__ = ("exit_code",)
+
+    def __init__(self, code: int = 0) -> None:
+        self.exit_code: int = code

+ 301 - 0
venv/lib/python3.9/site-packages/click/formatting.py

@@ -0,0 +1,301 @@
+import typing as t
+from contextlib import contextmanager
+from gettext import gettext as _
+
+from ._compat import term_len
+from .parser import split_opt
+
+# Can force a width.  This is used by the test system
+FORCED_WIDTH: t.Optional[int] = None
+
+
+def measure_table(rows: t.Iterable[t.Tuple[str, str]]) -> t.Tuple[int, ...]:
+    widths: t.Dict[int, int] = {}
+
+    for row in rows:
+        for idx, col in enumerate(row):
+            widths[idx] = max(widths.get(idx, 0), term_len(col))
+
+    return tuple(y for x, y in sorted(widths.items()))
+
+
+def iter_rows(
+    rows: t.Iterable[t.Tuple[str, str]], col_count: int
+) -> t.Iterator[t.Tuple[str, ...]]:
+    for row in rows:
+        yield row + ("",) * (col_count - len(row))
+
+
+def wrap_text(
+    text: str,
+    width: int = 78,
+    initial_indent: str = "",
+    subsequent_indent: str = "",
+    preserve_paragraphs: bool = False,
+) -> str:
+    """A helper function that intelligently wraps text.  By default, it
+    assumes that it operates on a single paragraph of text but if the
+    `preserve_paragraphs` parameter is provided it will intelligently
+    handle paragraphs (defined by two empty lines).
+
+    If paragraphs are handled, a paragraph can be prefixed with an empty
+    line containing the ``\\b`` character (``\\x08``) to indicate that
+    no rewrapping should happen in that block.
+
+    :param text: the text that should be rewrapped.
+    :param width: the maximum width for the text.
+    :param initial_indent: the initial indent that should be placed on the
+                           first line as a string.
+    :param subsequent_indent: the indent string that should be placed on
+                              each consecutive line.
+    :param preserve_paragraphs: if this flag is set then the wrapping will
+                                intelligently handle paragraphs.
+    """
+    from ._textwrap import TextWrapper
+
+    text = text.expandtabs()
+    wrapper = TextWrapper(
+        width,
+        initial_indent=initial_indent,
+        subsequent_indent=subsequent_indent,
+        replace_whitespace=False,
+    )
+    if not preserve_paragraphs:
+        return wrapper.fill(text)
+
+    p: t.List[t.Tuple[int, bool, str]] = []
+    buf: t.List[str] = []
+    indent = None
+
+    def _flush_par() -> None:
+        if not buf:
+            return
+        if buf[0].strip() == "\b":
+            p.append((indent or 0, True, "\n".join(buf[1:])))
+        else:
+            p.append((indent or 0, False, " ".join(buf)))
+        del buf[:]
+
+    for line in text.splitlines():
+        if not line:
+            _flush_par()
+            indent = None
+        else:
+            if indent is None:
+                orig_len = term_len(line)
+                line = line.lstrip()
+                indent = orig_len - term_len(line)
+            buf.append(line)
+    _flush_par()
+
+    rv = []
+    for indent, raw, text in p:
+        with wrapper.extra_indent(" " * indent):
+            if raw:
+                rv.append(wrapper.indent_only(text))
+            else:
+                rv.append(wrapper.fill(text))
+
+    return "\n\n".join(rv)
+
+
+class HelpFormatter:
+    """This class helps with formatting text-based help pages.  It's
+    usually just needed for very special internal cases, but it's also
+    exposed so that developers can write their own fancy outputs.
+
+    At present, it always writes into memory.
+
+    :param indent_increment: the additional increment for each level.
+    :param width: the width for the text.  This defaults to the terminal
+                  width clamped to a maximum of 78.
+    """
+
+    def __init__(
+        self,
+        indent_increment: int = 2,
+        width: t.Optional[int] = None,
+        max_width: t.Optional[int] = None,
+    ) -> None:
+        import shutil
+
+        self.indent_increment = indent_increment
+        if max_width is None:
+            max_width = 80
+        if width is None:
+            width = FORCED_WIDTH
+            if width is None:
+                width = max(min(shutil.get_terminal_size().columns, max_width) - 2, 50)
+        self.width = width
+        self.current_indent = 0
+        self.buffer: t.List[str] = []
+
+    def write(self, string: str) -> None:
+        """Writes a unicode string into the internal buffer."""
+        self.buffer.append(string)
+
+    def indent(self) -> None:
+        """Increases the indentation."""
+        self.current_indent += self.indent_increment
+
+    def dedent(self) -> None:
+        """Decreases the indentation."""
+        self.current_indent -= self.indent_increment
+
+    def write_usage(
+        self, prog: str, args: str = "", prefix: t.Optional[str] = None
+    ) -> None:
+        """Writes a usage line into the buffer.
+
+        :param prog: the program name.
+        :param args: whitespace separated list of arguments.
+        :param prefix: The prefix for the first line. Defaults to
+            ``"Usage: "``.
+        """
+        if prefix is None:
+            prefix = f"{_('Usage:')} "
+
+        usage_prefix = f"{prefix:>{self.current_indent}}{prog} "
+        text_width = self.width - self.current_indent
+
+        if text_width >= (term_len(usage_prefix) + 20):
+            # The arguments will fit to the right of the prefix.
+            indent = " " * term_len(usage_prefix)
+            self.write(
+                wrap_text(
+                    args,
+                    text_width,
+                    initial_indent=usage_prefix,
+                    subsequent_indent=indent,
+                )
+            )
+        else:
+            # The prefix is too long, put the arguments on the next line.
+            self.write(usage_prefix)
+            self.write("\n")
+            indent = " " * (max(self.current_indent, term_len(prefix)) + 4)
+            self.write(
+                wrap_text(
+                    args, text_width, initial_indent=indent, subsequent_indent=indent
+                )
+            )
+
+        self.write("\n")
+
+    def write_heading(self, heading: str) -> None:
+        """Writes a heading into the buffer."""
+        self.write(f"{'':>{self.current_indent}}{heading}:\n")
+
+    def write_paragraph(self) -> None:
+        """Writes a paragraph into the buffer."""
+        if self.buffer:
+            self.write("\n")
+
+    def write_text(self, text: str) -> None:
+        """Writes re-indented text into the buffer.  This rewraps and
+        preserves paragraphs.
+        """
+        indent = " " * self.current_indent
+        self.write(
+            wrap_text(
+                text,
+                self.width,
+                initial_indent=indent,
+                subsequent_indent=indent,
+                preserve_paragraphs=True,
+            )
+        )
+        self.write("\n")
+
+    def write_dl(
+        self,
+        rows: t.Sequence[t.Tuple[str, str]],
+        col_max: int = 30,
+        col_spacing: int = 2,
+    ) -> None:
+        """Writes a definition list into the buffer.  This is how options
+        and commands are usually formatted.
+
+        :param rows: a list of two item tuples for the terms and values.
+        :param col_max: the maximum width of the first column.
+        :param col_spacing: the number of spaces between the first and
+                            second column.
+        """
+        rows = list(rows)
+        widths = measure_table(rows)
+        if len(widths) != 2:
+            raise TypeError("Expected two columns for definition list")
+
+        first_col = min(widths[0], col_max) + col_spacing
+
+        for first, second in iter_rows(rows, len(widths)):
+            self.write(f"{'':>{self.current_indent}}{first}")
+            if not second:
+                self.write("\n")
+                continue
+            if term_len(first) <= first_col - col_spacing:
+                self.write(" " * (first_col - term_len(first)))
+            else:
+                self.write("\n")
+                self.write(" " * (first_col + self.current_indent))
+
+            text_width = max(self.width - first_col - 2, 10)
+            wrapped_text = wrap_text(second, text_width, preserve_paragraphs=True)
+            lines = wrapped_text.splitlines()
+
+            if lines:
+                self.write(f"{lines[0]}\n")
+
+                for line in lines[1:]:
+                    self.write(f"{'':>{first_col + self.current_indent}}{line}\n")
+            else:
+                self.write("\n")
+
+    @contextmanager
+    def section(self, name: str) -> t.Iterator[None]:
+        """Helpful context manager that writes a paragraph, a heading,
+        and the indents.
+
+        :param name: the section name that is written as heading.
+        """
+        self.write_paragraph()
+        self.write_heading(name)
+        self.indent()
+        try:
+            yield
+        finally:
+            self.dedent()
+
+    @contextmanager
+    def indentation(self) -> t.Iterator[None]:
+        """A context manager that increases the indentation."""
+        self.indent()
+        try:
+            yield
+        finally:
+            self.dedent()
+
+    def getvalue(self) -> str:
+        """Returns the buffer contents."""
+        return "".join(self.buffer)
+
+
+def join_options(options: t.Sequence[str]) -> t.Tuple[str, bool]:
+    """Given a list of option strings this joins them in the most appropriate
+    way and returns them in the form ``(formatted_string,
+    any_prefix_is_slash)`` where the second item in the tuple is a flag that
+    indicates if any of the option prefixes was a slash.
+    """
+    rv = []
+    any_prefix_is_slash = False
+
+    for opt in options:
+        prefix = split_opt(opt)[0]
+
+        if prefix == "/":
+            any_prefix_is_slash = True
+
+        rv.append((len(prefix), opt))
+
+    rv.sort(key=lambda x: x[0])
+    return ", ".join(x[1] for x in rv), any_prefix_is_slash

+ 67 - 0
venv/lib/python3.9/site-packages/click/globals.py

@@ -0,0 +1,67 @@
+import typing as t
+from threading import local
+
+if t.TYPE_CHECKING:
+    import typing_extensions as te
+
+    from .core import Context
+
+_local = local()
+
+
+@t.overload
+def get_current_context(silent: "te.Literal[False]" = False) -> "Context": ...
+
+
+@t.overload
+def get_current_context(silent: bool = ...) -> t.Optional["Context"]: ...
+
+
+def get_current_context(silent: bool = False) -> t.Optional["Context"]:
+    """Returns the current click context.  This can be used as a way to
+    access the current context object from anywhere.  This is a more implicit
+    alternative to the :func:`pass_context` decorator.  This function is
+    primarily useful for helpers such as :func:`echo` which might be
+    interested in changing its behavior based on the current context.
+
+    To push the current context, :meth:`Context.scope` can be used.
+
+    .. versionadded:: 5.0
+
+    :param silent: if set to `True` the return value is `None` if no context
+                   is available.  The default behavior is to raise a
+                   :exc:`RuntimeError`.
+    """
+    try:
+        return t.cast("Context", _local.stack[-1])
+    except (AttributeError, IndexError) as e:
+        if not silent:
+            raise RuntimeError("There is no active click context.") from e
+
+    return None
+
+
+def push_context(ctx: "Context") -> None:
+    """Pushes a new context to the current stack."""
+    _local.__dict__.setdefault("stack", []).append(ctx)
+
+
+def pop_context() -> None:
+    """Removes the top level from the stack."""
+    _local.stack.pop()
+
+
+def resolve_color_default(color: t.Optional[bool] = None) -> t.Optional[bool]:
+    """Internal helper to get the default value of the color flag.  If a
+    value is passed it's returned unchanged, otherwise it's looked up from
+    the current context.
+    """
+    if color is not None:
+        return color
+
+    ctx = get_current_context(silent=True)
+
+    if ctx is not None:
+        return ctx.color
+
+    return None

+ 531 - 0
venv/lib/python3.9/site-packages/click/parser.py

@@ -0,0 +1,531 @@
+"""
+This module started out as largely a copy paste from the stdlib's
+optparse module with the features removed that we do not need from
+optparse because we implement them in Click on a higher level (for
+instance type handling, help formatting and a lot more).
+
+The plan is to remove more and more from here over time.
+
+The reason this is a different module and not optparse from the stdlib
+is that there are differences in 2.x and 3.x about the error messages
+generated and optparse in the stdlib uses gettext for no good reason
+and might cause us issues.
+
+Click uses parts of optparse written by Gregory P. Ward and maintained
+by the Python Software Foundation. This is limited to code in parser.py.
+
+Copyright 2001-2006 Gregory P. Ward. All rights reserved.
+Copyright 2002-2006 Python Software Foundation. All rights reserved.
+"""
+
+# This code uses parts of optparse written by Gregory P. Ward and
+# maintained by the Python Software Foundation.
+# Copyright 2001-2006 Gregory P. Ward
+# Copyright 2002-2006 Python Software Foundation
+import typing as t
+from collections import deque
+from gettext import gettext as _
+from gettext import ngettext
+
+from .exceptions import BadArgumentUsage
+from .exceptions import BadOptionUsage
+from .exceptions import NoSuchOption
+from .exceptions import UsageError
+
+if t.TYPE_CHECKING:
+    import typing_extensions as te
+
+    from .core import Argument as CoreArgument
+    from .core import Context
+    from .core import Option as CoreOption
+    from .core import Parameter as CoreParameter
+
+V = t.TypeVar("V")
+
+# Sentinel value that indicates an option was passed as a flag without a
+# value but is not a flag option. Option.consume_value uses this to
+# prompt or use the flag_value.
+_flag_needs_value = object()
+
+
+def _unpack_args(
+    args: t.Sequence[str], nargs_spec: t.Sequence[int]
+) -> t.Tuple[t.Sequence[t.Union[str, t.Sequence[t.Optional[str]], None]], t.List[str]]:
+    """Given an iterable of arguments and an iterable of nargs specifications,
+    it returns a tuple with all the unpacked arguments at the first index
+    and all remaining arguments as the second.
+
+    The nargs specification is the number of arguments that should be consumed
+    or `-1` to indicate that this position should eat up all the remainders.
+
+    Missing items are filled with `None`.
+    """
+    args = deque(args)
+    nargs_spec = deque(nargs_spec)
+    rv: t.List[t.Union[str, t.Tuple[t.Optional[str], ...], None]] = []
+    spos: t.Optional[int] = None
+
+    def _fetch(c: "te.Deque[V]") -> t.Optional[V]:
+        try:
+            if spos is None:
+                return c.popleft()
+            else:
+                return c.pop()
+        except IndexError:
+            return None
+
+    while nargs_spec:
+        nargs = _fetch(nargs_spec)
+
+        if nargs is None:
+            continue
+
+        if nargs == 1:
+            rv.append(_fetch(args))
+        elif nargs > 1:
+            x = [_fetch(args) for _ in range(nargs)]
+
+            # If we're reversed, we're pulling in the arguments in reverse,
+            # so we need to turn them around.
+            if spos is not None:
+                x.reverse()
+
+            rv.append(tuple(x))
+        elif nargs < 0:
+            if spos is not None:
+                raise TypeError("Cannot have two nargs < 0")
+
+            spos = len(rv)
+            rv.append(None)
+
+    # spos is the position of the wildcard (star).  If it's not `None`,
+    # we fill it with the remainder.
+    if spos is not None:
+        rv[spos] = tuple(args)
+        args = []
+        rv[spos + 1 :] = reversed(rv[spos + 1 :])
+
+    return tuple(rv), list(args)
+
+
+def split_opt(opt: str) -> t.Tuple[str, str]:
+    first = opt[:1]
+    if first.isalnum():
+        return "", opt
+    if opt[1:2] == first:
+        return opt[:2], opt[2:]
+    return first, opt[1:]
+
+
+def normalize_opt(opt: str, ctx: t.Optional["Context"]) -> str:
+    if ctx is None or ctx.token_normalize_func is None:
+        return opt
+    prefix, opt = split_opt(opt)
+    return f"{prefix}{ctx.token_normalize_func(opt)}"
+
+
+def split_arg_string(string: str) -> t.List[str]:
+    """Split an argument string as with :func:`shlex.split`, but don't
+    fail if the string is incomplete. Ignores a missing closing quote or
+    incomplete escape sequence and uses the partial token as-is.
+
+    .. code-block:: python
+
+        split_arg_string("example 'my file")
+        ["example", "my file"]
+
+        split_arg_string("example my\\")
+        ["example", "my"]
+
+    :param string: String to split.
+    """
+    import shlex
+
+    lex = shlex.shlex(string, posix=True)
+    lex.whitespace_split = True
+    lex.commenters = ""
+    out = []
+
+    try:
+        for token in lex:
+            out.append(token)
+    except ValueError:
+        # Raised when end-of-string is reached in an invalid state. Use
+        # the partial token as-is. The quote or escape character is in
+        # lex.state, not lex.token.
+        out.append(lex.token)
+
+    return out
+
+
+class Option:
+    def __init__(
+        self,
+        obj: "CoreOption",
+        opts: t.Sequence[str],
+        dest: t.Optional[str],
+        action: t.Optional[str] = None,
+        nargs: int = 1,
+        const: t.Optional[t.Any] = None,
+    ):
+        self._short_opts = []
+        self._long_opts = []
+        self.prefixes: t.Set[str] = set()
+
+        for opt in opts:
+            prefix, value = split_opt(opt)
+            if not prefix:
+                raise ValueError(f"Invalid start character for option ({opt})")
+            self.prefixes.add(prefix[0])
+            if len(prefix) == 1 and len(value) == 1:
+                self._short_opts.append(opt)
+            else:
+                self._long_opts.append(opt)
+                self.prefixes.add(prefix)
+
+        if action is None:
+            action = "store"
+
+        self.dest = dest
+        self.action = action
+        self.nargs = nargs
+        self.const = const
+        self.obj = obj
+
+    @property
+    def takes_value(self) -> bool:
+        return self.action in ("store", "append")
+
+    def process(self, value: t.Any, state: "ParsingState") -> None:
+        if self.action == "store":
+            state.opts[self.dest] = value  # type: ignore
+        elif self.action == "store_const":
+            state.opts[self.dest] = self.const  # type: ignore
+        elif self.action == "append":
+            state.opts.setdefault(self.dest, []).append(value)  # type: ignore
+        elif self.action == "append_const":
+            state.opts.setdefault(self.dest, []).append(self.const)  # type: ignore
+        elif self.action == "count":
+            state.opts[self.dest] = state.opts.get(self.dest, 0) + 1  # type: ignore
+        else:
+            raise ValueError(f"unknown action '{self.action}'")
+        state.order.append(self.obj)
+
+
+class Argument:
+    def __init__(self, obj: "CoreArgument", dest: t.Optional[str], nargs: int = 1):
+        self.dest = dest
+        self.nargs = nargs
+        self.obj = obj
+
+    def process(
+        self,
+        value: t.Union[t.Optional[str], t.Sequence[t.Optional[str]]],
+        state: "ParsingState",
+    ) -> None:
+        if self.nargs > 1:
+            assert value is not None
+            holes = sum(1 for x in value if x is None)
+            if holes == len(value):
+                value = None
+            elif holes != 0:
+                raise BadArgumentUsage(
+                    _("Argument {name!r} takes {nargs} values.").format(
+                        name=self.dest, nargs=self.nargs
+                    )
+                )
+
+        if self.nargs == -1 and self.obj.envvar is not None and value == ():
+            # Replace empty tuple with None so that a value from the
+            # environment may be tried.
+            value = None
+
+        state.opts[self.dest] = value  # type: ignore
+        state.order.append(self.obj)
+
+
+class ParsingState:
+    def __init__(self, rargs: t.List[str]) -> None:
+        self.opts: t.Dict[str, t.Any] = {}
+        self.largs: t.List[str] = []
+        self.rargs = rargs
+        self.order: t.List[CoreParameter] = []
+
+
+class OptionParser:
+    """The option parser is an internal class that is ultimately used to
+    parse options and arguments.  It's modelled after optparse and brings
+    a similar but vastly simplified API.  It should generally not be used
+    directly as the high level Click classes wrap it for you.
+
+    It's not nearly as extensible as optparse or argparse as it does not
+    implement features that are implemented on a higher level (such as
+    types or defaults).
+
+    :param ctx: optionally the :class:`~click.Context` where this parser
+                should go with.
+    """
+
+    def __init__(self, ctx: t.Optional["Context"] = None) -> None:
+        #: The :class:`~click.Context` for this parser.  This might be
+        #: `None` for some advanced use cases.
+        self.ctx = ctx
+        #: This controls how the parser deals with interspersed arguments.
+        #: If this is set to `False`, the parser will stop on the first
+        #: non-option.  Click uses this to implement nested subcommands
+        #: safely.
+        self.allow_interspersed_args: bool = True
+        #: This tells the parser how to deal with unknown options.  By
+        #: default it will error out (which is sensible), but there is a
+        #: second mode where it will ignore it and continue processing
+        #: after shifting all the unknown options into the resulting args.
+        self.ignore_unknown_options: bool = False
+
+        if ctx is not None:
+            self.allow_interspersed_args = ctx.allow_interspersed_args
+            self.ignore_unknown_options = ctx.ignore_unknown_options
+
+        self._short_opt: t.Dict[str, Option] = {}
+        self._long_opt: t.Dict[str, Option] = {}
+        self._opt_prefixes = {"-", "--"}
+        self._args: t.List[Argument] = []
+
+    def add_option(
+        self,
+        obj: "CoreOption",
+        opts: t.Sequence[str],
+        dest: t.Optional[str],
+        action: t.Optional[str] = None,
+        nargs: int = 1,
+        const: t.Optional[t.Any] = None,
+    ) -> None:
+        """Adds a new option named `dest` to the parser.  The destination
+        is not inferred (unlike with optparse) and needs to be explicitly
+        provided.  Action can be any of ``store``, ``store_const``,
+        ``append``, ``append_const`` or ``count``.
+
+        The `obj` can be used to identify the option in the order list
+        that is returned from the parser.
+        """
+        opts = [normalize_opt(opt, self.ctx) for opt in opts]
+        option = Option(obj, opts, dest, action=action, nargs=nargs, const=const)
+        self._opt_prefixes.update(option.prefixes)
+        for opt in option._short_opts:
+            self._short_opt[opt] = option
+        for opt in option._long_opts:
+            self._long_opt[opt] = option
+
+    def add_argument(
+        self, obj: "CoreArgument", dest: t.Optional[str], nargs: int = 1
+    ) -> None:
+        """Adds a positional argument named `dest` to the parser.
+
+        The `obj` can be used to identify the option in the order list
+        that is returned from the parser.
+        """
+        self._args.append(Argument(obj, dest=dest, nargs=nargs))
+
+    def parse_args(
+        self, args: t.List[str]
+    ) -> t.Tuple[t.Dict[str, t.Any], t.List[str], t.List["CoreParameter"]]:
+        """Parses positional arguments and returns ``(values, args, order)``
+        for the parsed options and arguments as well as the leftover
+        arguments if there are any.  The order is a list of objects as they
+        appear on the command line.  If arguments appear multiple times they
+        will be memorized multiple times as well.
+        """
+        state = ParsingState(args)
+        try:
+            self._process_args_for_options(state)
+            self._process_args_for_args(state)
+        except UsageError:
+            if self.ctx is None or not self.ctx.resilient_parsing:
+                raise
+        return state.opts, state.largs, state.order
+
+    def _process_args_for_args(self, state: ParsingState) -> None:
+        pargs, args = _unpack_args(
+            state.largs + state.rargs, [x.nargs for x in self._args]
+        )
+
+        for idx, arg in enumerate(self._args):
+            arg.process(pargs[idx], state)
+
+        state.largs = args
+        state.rargs = []
+
+    def _process_args_for_options(self, state: ParsingState) -> None:
+        while state.rargs:
+            arg = state.rargs.pop(0)
+            arglen = len(arg)
+            # Double dashes always handled explicitly regardless of what
+            # prefixes are valid.
+            if arg == "--":
+                return
+            elif arg[:1] in self._opt_prefixes and arglen > 1:
+                self._process_opts(arg, state)
+            elif self.allow_interspersed_args:
+                state.largs.append(arg)
+            else:
+                state.rargs.insert(0, arg)
+                return
+
+        # Say this is the original argument list:
+        # [arg0, arg1, ..., arg(i-1), arg(i), arg(i+1), ..., arg(N-1)]
+        #                            ^
+        # (we are about to process arg(i)).
+        #
+        # Then rargs is [arg(i), ..., arg(N-1)] and largs is a *subset* of
+        # [arg0, ..., arg(i-1)] (any options and their arguments will have
+        # been removed from largs).
+        #
+        # The while loop will usually consume 1 or more arguments per pass.
+        # If it consumes 1 (eg. arg is an option that takes no arguments),
+        # then after _process_arg() is done the situation is:
+        #
+        #   largs = subset of [arg0, ..., arg(i)]
+        #   rargs = [arg(i+1), ..., arg(N-1)]
+        #
+        # If allow_interspersed_args is false, largs will always be
+        # *empty* -- still a subset of [arg0, ..., arg(i-1)], but
+        # not a very interesting subset!
+
+    def _match_long_opt(
+        self, opt: str, explicit_value: t.Optional[str], state: ParsingState
+    ) -> None:
+        if opt not in self._long_opt:
+            from difflib import get_close_matches
+
+            possibilities = get_close_matches(opt, self._long_opt)
+            raise NoSuchOption(opt, possibilities=possibilities, ctx=self.ctx)
+
+        option = self._long_opt[opt]
+        if option.takes_value:
+            # At this point it's safe to modify rargs by injecting the
+            # explicit value, because no exception is raised in this
+            # branch.  This means that the inserted value will be fully
+            # consumed.
+            if explicit_value is not None:
+                state.rargs.insert(0, explicit_value)
+
+            value = self._get_value_from_state(opt, option, state)
+
+        elif explicit_value is not None:
+            raise BadOptionUsage(
+                opt, _("Option {name!r} does not take a value.").format(name=opt)
+            )
+
+        else:
+            value = None
+
+        option.process(value, state)
+
+    def _match_short_opt(self, arg: str, state: ParsingState) -> None:
+        stop = False
+        i = 1
+        prefix = arg[0]
+        unknown_options = []
+
+        for ch in arg[1:]:
+            opt = normalize_opt(f"{prefix}{ch}", self.ctx)
+            option = self._short_opt.get(opt)
+            i += 1
+
+            if not option:
+                if self.ignore_unknown_options:
+                    unknown_options.append(ch)
+                    continue
+                raise NoSuchOption(opt, ctx=self.ctx)
+            if option.takes_value:
+                # Any characters left in arg?  Pretend they're the
+                # next arg, and stop consuming characters of arg.
+                if i < len(arg):
+                    state.rargs.insert(0, arg[i:])
+                    stop = True
+
+                value = self._get_value_from_state(opt, option, state)
+
+            else:
+                value = None
+
+            option.process(value, state)
+
+            if stop:
+                break
+
+        # If we got any unknown options we recombine the string of the
+        # remaining options and re-attach the prefix, then report that
+        # to the state as new larg.  This way there is basic combinatorics
+        # that can be achieved while still ignoring unknown arguments.
+        if self.ignore_unknown_options and unknown_options:
+            state.largs.append(f"{prefix}{''.join(unknown_options)}")
+
+    def _get_value_from_state(
+        self, option_name: str, option: Option, state: ParsingState
+    ) -> t.Any:
+        nargs = option.nargs
+
+        if len(state.rargs) < nargs:
+            if option.obj._flag_needs_value:
+                # Option allows omitting the value.
+                value = _flag_needs_value
+            else:
+                raise BadOptionUsage(
+                    option_name,
+                    ngettext(
+                        "Option {name!r} requires an argument.",
+                        "Option {name!r} requires {nargs} arguments.",
+                        nargs,
+                    ).format(name=option_name, nargs=nargs),
+                )
+        elif nargs == 1:
+            next_rarg = state.rargs[0]
+
+            if (
+                option.obj._flag_needs_value
+                and isinstance(next_rarg, str)
+                and next_rarg[:1] in self._opt_prefixes
+                and len(next_rarg) > 1
+            ):
+                # The next arg looks like the start of an option, don't
+                # use it as the value if omitting the value is allowed.
+                value = _flag_needs_value
+            else:
+                value = state.rargs.pop(0)
+        else:
+            value = tuple(state.rargs[:nargs])
+            del state.rargs[:nargs]
+
+        return value
+
+    def _process_opts(self, arg: str, state: ParsingState) -> None:
+        explicit_value = None
+        # Long option handling happens in two parts.  The first part is
+        # supporting explicitly attached values.  In any case, we will try
+        # to long match the option first.
+        if "=" in arg:
+            long_opt, explicit_value = arg.split("=", 1)
+        else:
+            long_opt = arg
+        norm_long_opt = normalize_opt(long_opt, self.ctx)
+
+        # At this point we will match the (assumed) long option through
+        # the long option matching code.  Note that this allows options
+        # like "-foo" to be matched as long options.
+        try:
+            self._match_long_opt(norm_long_opt, explicit_value, state)
+        except NoSuchOption:
+            # At this point the long option matching failed, and we need
+            # to try with short options.  However there is a special rule
+            # which says, that if we have a two character options prefix
+            # (applies to "--foo" for instance), we do not dispatch to the
+            # short option code and will instead raise the no option
+            # error.
+            if arg[:2] not in self._opt_prefixes:
+                self._match_short_opt(arg, state)
+                return
+
+            if not self.ignore_unknown_options:
+                raise
+
+            state.largs.append(arg)

+ 0 - 0
venv/lib/python3.9/site-packages/click/py.typed


+ 603 - 0
venv/lib/python3.9/site-packages/click/shell_completion.py

@@ -0,0 +1,603 @@
+import os
+import re
+import typing as t
+from gettext import gettext as _
+
+from .core import Argument
+from .core import BaseCommand
+from .core import Context
+from .core import MultiCommand
+from .core import Option
+from .core import Parameter
+from .core import ParameterSource
+from .parser import split_arg_string
+from .utils import echo
+
+
+def shell_complete(
+    cli: BaseCommand,
+    ctx_args: t.MutableMapping[str, t.Any],
+    prog_name: str,
+    complete_var: str,
+    instruction: str,
+) -> int:
+    """Perform shell completion for the given CLI program.
+
+    :param cli: Command being called.
+    :param ctx_args: Extra arguments to pass to
+        ``cli.make_context``.
+    :param prog_name: Name of the executable in the shell.
+    :param complete_var: Name of the environment variable that holds
+        the completion instruction.
+    :param instruction: Value of ``complete_var`` with the completion
+        instruction and shell, in the form ``instruction_shell``.
+    :return: Status code to exit with.
+    """
+    shell, _, instruction = instruction.partition("_")
+    comp_cls = get_completion_class(shell)
+
+    if comp_cls is None:
+        return 1
+
+    comp = comp_cls(cli, ctx_args, prog_name, complete_var)
+
+    if instruction == "source":
+        echo(comp.source())
+        return 0
+
+    if instruction == "complete":
+        echo(comp.complete())
+        return 0
+
+    return 1
+
+
+class CompletionItem:
+    """Represents a completion value and metadata about the value. The
+    default metadata is ``type`` to indicate special shell handling,
+    and ``help`` if a shell supports showing a help string next to the
+    value.
+
+    Arbitrary parameters can be passed when creating the object, and
+    accessed using ``item.attr``. If an attribute wasn't passed,
+    accessing it returns ``None``.
+
+    :param value: The completion suggestion.
+    :param type: Tells the shell script to provide special completion
+        support for the type. Click uses ``"dir"`` and ``"file"``.
+    :param help: String shown next to the value if supported.
+    :param kwargs: Arbitrary metadata. The built-in implementations
+        don't use this, but custom type completions paired with custom
+        shell support could use it.
+    """
+
+    __slots__ = ("value", "type", "help", "_info")
+
+    def __init__(
+        self,
+        value: t.Any,
+        type: str = "plain",
+        help: t.Optional[str] = None,
+        **kwargs: t.Any,
+    ) -> None:
+        self.value: t.Any = value
+        self.type: str = type
+        self.help: t.Optional[str] = help
+        self._info = kwargs
+
+    def __getattr__(self, name: str) -> t.Any:
+        return self._info.get(name)
+
+
+# Only Bash >= 4.4 has the nosort option.
+_SOURCE_BASH = """\
+%(complete_func)s() {
+    local IFS=$'\\n'
+    local response
+
+    response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD \
+%(complete_var)s=bash_complete $1)
+
+    for completion in $response; do
+        IFS=',' read type value <<< "$completion"
+
+        if [[ $type == 'dir' ]]; then
+            COMPREPLY=()
+            compopt -o dirnames
+        elif [[ $type == 'file' ]]; then
+            COMPREPLY=()
+            compopt -o default
+        elif [[ $type == 'plain' ]]; then
+            COMPREPLY+=($value)
+        fi
+    done
+
+    return 0
+}
+
+%(complete_func)s_setup() {
+    complete -o nosort -F %(complete_func)s %(prog_name)s
+}
+
+%(complete_func)s_setup;
+"""
+
+_SOURCE_ZSH = """\
+#compdef %(prog_name)s
+
+%(complete_func)s() {
+    local -a completions
+    local -a completions_with_descriptions
+    local -a response
+    (( ! $+commands[%(prog_name)s] )) && return 1
+
+    response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) \
+%(complete_var)s=zsh_complete %(prog_name)s)}")
+
+    for type key descr in ${response}; do
+        if [[ "$type" == "plain" ]]; then
+            if [[ "$descr" == "_" ]]; then
+                completions+=("$key")
+            else
+                completions_with_descriptions+=("$key":"$descr")
+            fi
+        elif [[ "$type" == "dir" ]]; then
+            _path_files -/
+        elif [[ "$type" == "file" ]]; then
+            _path_files -f
+        fi
+    done
+
+    if [ -n "$completions_with_descriptions" ]; then
+        _describe -V unsorted completions_with_descriptions -U
+    fi
+
+    if [ -n "$completions" ]; then
+        compadd -U -V unsorted -a completions
+    fi
+}
+
+if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
+    # autoload from fpath, call function directly
+    %(complete_func)s "$@"
+else
+    # eval/source/. command, register function for later
+    compdef %(complete_func)s %(prog_name)s
+fi
+"""
+
+_SOURCE_FISH = """\
+function %(complete_func)s;
+    set -l response (env %(complete_var)s=fish_complete COMP_WORDS=(commandline -cp) \
+COMP_CWORD=(commandline -t) %(prog_name)s);
+
+    for completion in $response;
+        set -l metadata (string split "," $completion);
+
+        if test $metadata[1] = "dir";
+            __fish_complete_directories $metadata[2];
+        else if test $metadata[1] = "file";
+            __fish_complete_path $metadata[2];
+        else if test $metadata[1] = "plain";
+            echo $metadata[2];
+        end;
+    end;
+end;
+
+complete --no-files --command %(prog_name)s --arguments \
+"(%(complete_func)s)";
+"""
+
+
+class ShellComplete:
+    """Base class for providing shell completion support. A subclass for
+    a given shell will override attributes and methods to implement the
+    completion instructions (``source`` and ``complete``).
+
+    :param cli: Command being called.
+    :param prog_name: Name of the executable in the shell.
+    :param complete_var: Name of the environment variable that holds
+        the completion instruction.
+
+    .. versionadded:: 8.0
+    """
+
+    name: t.ClassVar[str]
+    """Name to register the shell as with :func:`add_completion_class`.
+    This is used in completion instructions (``{name}_source`` and
+    ``{name}_complete``).
+    """
+
+    source_template: t.ClassVar[str]
+    """Completion script template formatted by :meth:`source`. This must
+    be provided by subclasses.
+    """
+
+    def __init__(
+        self,
+        cli: BaseCommand,
+        ctx_args: t.MutableMapping[str, t.Any],
+        prog_name: str,
+        complete_var: str,
+    ) -> None:
+        self.cli = cli
+        self.ctx_args = ctx_args
+        self.prog_name = prog_name
+        self.complete_var = complete_var
+
+    @property
+    def func_name(self) -> str:
+        """The name of the shell function defined by the completion
+        script.
+        """
+        safe_name = re.sub(r"\W*", "", self.prog_name.replace("-", "_"), flags=re.ASCII)
+        return f"_{safe_name}_completion"
+
+    def source_vars(self) -> t.Dict[str, t.Any]:
+        """Vars for formatting :attr:`source_template`.
+
+        By default this provides ``complete_func``, ``complete_var``,
+        and ``prog_name``.
+        """
+        return {
+            "complete_func": self.func_name,
+            "complete_var": self.complete_var,
+            "prog_name": self.prog_name,
+        }
+
+    def source(self) -> str:
+        """Produce the shell script that defines the completion
+        function. By default this ``%``-style formats
+        :attr:`source_template` with the dict returned by
+        :meth:`source_vars`.
+        """
+        return self.source_template % self.source_vars()
+
+    def get_completion_args(self) -> t.Tuple[t.List[str], str]:
+        """Use the env vars defined by the shell script to return a
+        tuple of ``args, incomplete``. This must be implemented by
+        subclasses.
+        """
+        raise NotImplementedError
+
+    def get_completions(
+        self, args: t.List[str], incomplete: str
+    ) -> t.List[CompletionItem]:
+        """Determine the context and last complete command or parameter
+        from the complete args. Call that object's ``shell_complete``
+        method to get the completions for the incomplete value.
+
+        :param args: List of complete args before the incomplete value.
+        :param incomplete: Value being completed. May be empty.
+        """
+        ctx = _resolve_context(self.cli, self.ctx_args, self.prog_name, args)
+        obj, incomplete = _resolve_incomplete(ctx, args, incomplete)
+        return obj.shell_complete(ctx, incomplete)
+
+    def format_completion(self, item: CompletionItem) -> str:
+        """Format a completion item into the form recognized by the
+        shell script. This must be implemented by subclasses.
+
+        :param item: Completion item to format.
+        """
+        raise NotImplementedError
+
+    def complete(self) -> str:
+        """Produce the completion data to send back to the shell.
+
+        By default this calls :meth:`get_completion_args`, gets the
+        completions, then calls :meth:`format_completion` for each
+        completion.
+        """
+        args, incomplete = self.get_completion_args()
+        completions = self.get_completions(args, incomplete)
+        out = [self.format_completion(item) for item in completions]
+        return "\n".join(out)
+
+
+class BashComplete(ShellComplete):
+    """Shell completion for Bash."""
+
+    name = "bash"
+    source_template = _SOURCE_BASH
+
+    @staticmethod
+    def _check_version() -> None:
+        import shutil
+        import subprocess
+
+        bash_exe = shutil.which("bash")
+
+        if bash_exe is None:
+            match = None
+        else:
+            output = subprocess.run(
+                [bash_exe, "--norc", "-c", 'echo "${BASH_VERSION}"'],
+                stdout=subprocess.PIPE,
+            )
+            match = re.search(r"^(\d+)\.(\d+)\.\d+", output.stdout.decode())
+
+        if match is not None:
+            major, minor = match.groups()
+
+            if major < "4" or major == "4" and minor < "4":
+                echo(
+                    _(
+                        "Shell completion is not supported for Bash"
+                        " versions older than 4.4."
+                    ),
+                    err=True,
+                )
+        else:
+            echo(
+                _("Couldn't detect Bash version, shell completion is not supported."),
+                err=True,
+            )
+
+    def source(self) -> str:
+        self._check_version()
+        return super().source()
+
+    def get_completion_args(self) -> t.Tuple[t.List[str], str]:
+        cwords = split_arg_string(os.environ["COMP_WORDS"])
+        cword = int(os.environ["COMP_CWORD"])
+        args = cwords[1:cword]
+
+        try:
+            incomplete = cwords[cword]
+        except IndexError:
+            incomplete = ""
+
+        return args, incomplete
+
+    def format_completion(self, item: CompletionItem) -> str:
+        return f"{item.type},{item.value}"
+
+
+class ZshComplete(ShellComplete):
+    """Shell completion for Zsh."""
+
+    name = "zsh"
+    source_template = _SOURCE_ZSH
+
+    def get_completion_args(self) -> t.Tuple[t.List[str], str]:
+        cwords = split_arg_string(os.environ["COMP_WORDS"])
+        cword = int(os.environ["COMP_CWORD"])
+        args = cwords[1:cword]
+
+        try:
+            incomplete = cwords[cword]
+        except IndexError:
+            incomplete = ""
+
+        return args, incomplete
+
+    def format_completion(self, item: CompletionItem) -> str:
+        return f"{item.type}\n{item.value}\n{item.help if item.help else '_'}"
+
+
+class FishComplete(ShellComplete):
+    """Shell completion for Fish."""
+
+    name = "fish"
+    source_template = _SOURCE_FISH
+
+    def get_completion_args(self) -> t.Tuple[t.List[str], str]:
+        cwords = split_arg_string(os.environ["COMP_WORDS"])
+        incomplete = os.environ["COMP_CWORD"]
+        args = cwords[1:]
+
+        # Fish stores the partial word in both COMP_WORDS and
+        # COMP_CWORD, remove it from complete args.
+        if incomplete and args and args[-1] == incomplete:
+            args.pop()
+
+        return args, incomplete
+
+    def format_completion(self, item: CompletionItem) -> str:
+        if item.help:
+            return f"{item.type},{item.value}\t{item.help}"
+
+        return f"{item.type},{item.value}"
+
+
+ShellCompleteType = t.TypeVar("ShellCompleteType", bound=t.Type[ShellComplete])
+
+
+_available_shells: t.Dict[str, t.Type[ShellComplete]] = {
+    "bash": BashComplete,
+    "fish": FishComplete,
+    "zsh": ZshComplete,
+}
+
+
+def add_completion_class(
+    cls: ShellCompleteType, name: t.Optional[str] = None
+) -> ShellCompleteType:
+    """Register a :class:`ShellComplete` subclass under the given name.
+    The name will be provided by the completion instruction environment
+    variable during completion.
+
+    :param cls: The completion class that will handle completion for the
+        shell.
+    :param name: Name to register the class under. Defaults to the
+        class's ``name`` attribute.
+    """
+    if name is None:
+        name = cls.name
+
+    _available_shells[name] = cls
+
+    return cls
+
+
+def get_completion_class(shell: str) -> t.Optional[t.Type[ShellComplete]]:
+    """Look up a registered :class:`ShellComplete` subclass by the name
+    provided by the completion instruction environment variable. If the
+    name isn't registered, returns ``None``.
+
+    :param shell: Name the class is registered under.
+    """
+    return _available_shells.get(shell)
+
+
+def _is_incomplete_argument(ctx: Context, param: Parameter) -> bool:
+    """Determine if the given parameter is an argument that can still
+    accept values.
+
+    :param ctx: Invocation context for the command represented by the
+        parsed complete args.
+    :param param: Argument object being checked.
+    """
+    if not isinstance(param, Argument):
+        return False
+
+    assert param.name is not None
+    # Will be None if expose_value is False.
+    value = ctx.params.get(param.name)
+    return (
+        param.nargs == -1
+        or ctx.get_parameter_source(param.name) is not ParameterSource.COMMANDLINE
+        or (
+            param.nargs > 1
+            and isinstance(value, (tuple, list))
+            and len(value) < param.nargs
+        )
+    )
+
+
+def _start_of_option(ctx: Context, value: str) -> bool:
+    """Check if the value looks like the start of an option."""
+    if not value:
+        return False
+
+    c = value[0]
+    return c in ctx._opt_prefixes
+
+
+def _is_incomplete_option(ctx: Context, args: t.List[str], param: Parameter) -> bool:
+    """Determine if the given parameter is an option that needs a value.
+
+    :param args: List of complete args before the incomplete value.
+    :param param: Option object being checked.
+    """
+    if not isinstance(param, Option):
+        return False
+
+    if param.is_flag or param.count:
+        return False
+
+    last_option = None
+
+    for index, arg in enumerate(reversed(args)):
+        if index + 1 > param.nargs:
+            break
+
+        if _start_of_option(ctx, arg):
+            last_option = arg
+
+    return last_option is not None and last_option in param.opts
+
+
+def _resolve_context(
+    cli: BaseCommand,
+    ctx_args: t.MutableMapping[str, t.Any],
+    prog_name: str,
+    args: t.List[str],
+) -> Context:
+    """Produce the context hierarchy starting with the command and
+    traversing the complete arguments. This only follows the commands,
+    it doesn't trigger input prompts or callbacks.
+
+    :param cli: Command being called.
+    :param prog_name: Name of the executable in the shell.
+    :param args: List of complete args before the incomplete value.
+    """
+    ctx_args["resilient_parsing"] = True
+    ctx = cli.make_context(prog_name, args.copy(), **ctx_args)
+    args = ctx.protected_args + ctx.args
+
+    while args:
+        command = ctx.command
+
+        if isinstance(command, MultiCommand):
+            if not command.chain:
+                name, cmd, args = command.resolve_command(ctx, args)
+
+                if cmd is None:
+                    return ctx
+
+                ctx = cmd.make_context(name, args, parent=ctx, resilient_parsing=True)
+                args = ctx.protected_args + ctx.args
+            else:
+                sub_ctx = ctx
+
+                while args:
+                    name, cmd, args = command.resolve_command(ctx, args)
+
+                    if cmd is None:
+                        return ctx
+
+                    sub_ctx = cmd.make_context(
+                        name,
+                        args,
+                        parent=ctx,
+                        allow_extra_args=True,
+                        allow_interspersed_args=False,
+                        resilient_parsing=True,
+                    )
+                    args = sub_ctx.args
+
+                ctx = sub_ctx
+                args = [*sub_ctx.protected_args, *sub_ctx.args]
+        else:
+            break
+
+    return ctx
+
+
+def _resolve_incomplete(
+    ctx: Context, args: t.List[str], incomplete: str
+) -> t.Tuple[t.Union[BaseCommand, Parameter], str]:
+    """Find the Click object that will handle the completion of the
+    incomplete value. Return the object and the incomplete value.
+
+    :param ctx: Invocation context for the command represented by
+        the parsed complete args.
+    :param args: List of complete args before the incomplete value.
+    :param incomplete: Value being completed. May be empty.
+    """
+    # Different shells treat an "=" between a long option name and
+    # value differently. Might keep the value joined, return the "="
+    # as a separate item, or return the split name and value. Always
+    # split and discard the "=" to make completion easier.
+    if incomplete == "=":
+        incomplete = ""
+    elif "=" in incomplete and _start_of_option(ctx, incomplete):
+        name, _, incomplete = incomplete.partition("=")
+        args.append(name)
+
+    # The "--" marker tells Click to stop treating values as options
+    # even if they start with the option character. If it hasn't been
+    # given and the incomplete arg looks like an option, the current
+    # command will provide option name completions.
+    if "--" not in args and _start_of_option(ctx, incomplete):
+        return ctx.command, incomplete
+
+    params = ctx.command.get_params(ctx)
+
+    # If the last complete arg is an option name with an incomplete
+    # value, the option will provide value completions.
+    for param in params:
+        if _is_incomplete_option(ctx, args, param):
+            return param, incomplete
+
+    # It's not an option name or value. The first argument without a
+    # parsed value will provide value completions.
+    for param in params:
+        if _is_incomplete_argument(ctx, param):
+            return param, incomplete
+
+    # There were no unparsed arguments, the command may be a group that
+    # will provide command name completions.
+    return ctx.command, incomplete

+ 784 - 0
venv/lib/python3.9/site-packages/click/termui.py

@@ -0,0 +1,784 @@
+import inspect
+import io
+import itertools
+import sys
+import typing as t
+from gettext import gettext as _
+
+from ._compat import isatty
+from ._compat import strip_ansi
+from .exceptions import Abort
+from .exceptions import UsageError
+from .globals import resolve_color_default
+from .types import Choice
+from .types import convert_type
+from .types import ParamType
+from .utils import echo
+from .utils import LazyFile
+
+if t.TYPE_CHECKING:
+    from ._termui_impl import ProgressBar
+
+V = t.TypeVar("V")
+
+# The prompt functions to use.  The doc tools currently override these
+# functions to customize how they work.
+visible_prompt_func: t.Callable[[str], str] = input
+
+_ansi_colors = {
+    "black": 30,
+    "red": 31,
+    "green": 32,
+    "yellow": 33,
+    "blue": 34,
+    "magenta": 35,
+    "cyan": 36,
+    "white": 37,
+    "reset": 39,
+    "bright_black": 90,
+    "bright_red": 91,
+    "bright_green": 92,
+    "bright_yellow": 93,
+    "bright_blue": 94,
+    "bright_magenta": 95,
+    "bright_cyan": 96,
+    "bright_white": 97,
+}
+_ansi_reset_all = "\033[0m"
+
+
+def hidden_prompt_func(prompt: str) -> str:
+    import getpass
+
+    return getpass.getpass(prompt)
+
+
+def _build_prompt(
+    text: str,
+    suffix: str,
+    show_default: bool = False,
+    default: t.Optional[t.Any] = None,
+    show_choices: bool = True,
+    type: t.Optional[ParamType] = None,
+) -> str:
+    prompt = text
+    if type is not None and show_choices and isinstance(type, Choice):
+        prompt += f" ({', '.join(map(str, type.choices))})"
+    if default is not None and show_default:
+        prompt = f"{prompt} [{_format_default(default)}]"
+    return f"{prompt}{suffix}"
+
+
+def _format_default(default: t.Any) -> t.Any:
+    if isinstance(default, (io.IOBase, LazyFile)) and hasattr(default, "name"):
+        return default.name
+
+    return default
+
+
+def prompt(
+    text: str,
+    default: t.Optional[t.Any] = None,
+    hide_input: bool = False,
+    confirmation_prompt: t.Union[bool, str] = False,
+    type: t.Optional[t.Union[ParamType, t.Any]] = None,
+    value_proc: t.Optional[t.Callable[[str], t.Any]] = None,
+    prompt_suffix: str = ": ",
+    show_default: bool = True,
+    err: bool = False,
+    show_choices: bool = True,
+) -> t.Any:
+    """Prompts a user for input.  This is a convenience function that can
+    be used to prompt a user for input later.
+
+    If the user aborts the input by sending an interrupt signal, this
+    function will catch it and raise a :exc:`Abort` exception.
+
+    :param text: the text to show for the prompt.
+    :param default: the default value to use if no input happens.  If this
+                    is not given it will prompt until it's aborted.
+    :param hide_input: if this is set to true then the input value will
+                       be hidden.
+    :param confirmation_prompt: Prompt a second time to confirm the
+        value. Can be set to a string instead of ``True`` to customize
+        the message.
+    :param type: the type to use to check the value against.
+    :param value_proc: if this parameter is provided it's a function that
+                       is invoked instead of the type conversion to
+                       convert a value.
+    :param prompt_suffix: a suffix that should be added to the prompt.
+    :param show_default: shows or hides the default value in the prompt.
+    :param err: if set to true the file defaults to ``stderr`` instead of
+                ``stdout``, the same as with echo.
+    :param show_choices: Show or hide choices if the passed type is a Choice.
+                         For example if type is a Choice of either day or week,
+                         show_choices is true and text is "Group by" then the
+                         prompt will be "Group by (day, week): ".
+
+    .. versionadded:: 8.0
+        ``confirmation_prompt`` can be a custom string.
+
+    .. versionadded:: 7.0
+        Added the ``show_choices`` parameter.
+
+    .. versionadded:: 6.0
+        Added unicode support for cmd.exe on Windows.
+
+    .. versionadded:: 4.0
+        Added the `err` parameter.
+
+    """
+
+    def prompt_func(text: str) -> str:
+        f = hidden_prompt_func if hide_input else visible_prompt_func
+        try:
+            # Write the prompt separately so that we get nice
+            # coloring through colorama on Windows
+            echo(text.rstrip(" "), nl=False, err=err)
+            # Echo a space to stdout to work around an issue where
+            # readline causes backspace to clear the whole line.
+            return f(" ")
+        except (KeyboardInterrupt, EOFError):
+            # getpass doesn't print a newline if the user aborts input with ^C.
+            # Allegedly this behavior is inherited from getpass(3).
+            # A doc bug has been filed at https://bugs.python.org/issue24711
+            if hide_input:
+                echo(None, err=err)
+            raise Abort() from None
+
+    if value_proc is None:
+        value_proc = convert_type(type, default)
+
+    prompt = _build_prompt(
+        text, prompt_suffix, show_default, default, show_choices, type
+    )
+
+    if confirmation_prompt:
+        if confirmation_prompt is True:
+            confirmation_prompt = _("Repeat for confirmation")
+
+        confirmation_prompt = _build_prompt(confirmation_prompt, prompt_suffix)
+
+    while True:
+        while True:
+            value = prompt_func(prompt)
+            if value:
+                break
+            elif default is not None:
+                value = default
+                break
+        try:
+            result = value_proc(value)
+        except UsageError as e:
+            if hide_input:
+                echo(_("Error: The value you entered was invalid."), err=err)
+            else:
+                echo(_("Error: {e.message}").format(e=e), err=err)
+            continue
+        if not confirmation_prompt:
+            return result
+        while True:
+            value2 = prompt_func(confirmation_prompt)
+            is_empty = not value and not value2
+            if value2 or is_empty:
+                break
+        if value == value2:
+            return result
+        echo(_("Error: The two entered values do not match."), err=err)
+
+
+def confirm(
+    text: str,
+    default: t.Optional[bool] = False,
+    abort: bool = False,
+    prompt_suffix: str = ": ",
+    show_default: bool = True,
+    err: bool = False,
+) -> bool:
+    """Prompts for confirmation (yes/no question).
+
+    If the user aborts the input by sending a interrupt signal this
+    function will catch it and raise a :exc:`Abort` exception.
+
+    :param text: the question to ask.
+    :param default: The default value to use when no input is given. If
+        ``None``, repeat until input is given.
+    :param abort: if this is set to `True` a negative answer aborts the
+                  exception by raising :exc:`Abort`.
+    :param prompt_suffix: a suffix that should be added to the prompt.
+    :param show_default: shows or hides the default value in the prompt.
+    :param err: if set to true the file defaults to ``stderr`` instead of
+                ``stdout``, the same as with echo.
+
+    .. versionchanged:: 8.0
+        Repeat until input is given if ``default`` is ``None``.
+
+    .. versionadded:: 4.0
+        Added the ``err`` parameter.
+    """
+    prompt = _build_prompt(
+        text,
+        prompt_suffix,
+        show_default,
+        "y/n" if default is None else ("Y/n" if default else "y/N"),
+    )
+
+    while True:
+        try:
+            # Write the prompt separately so that we get nice
+            # coloring through colorama on Windows
+            echo(prompt.rstrip(" "), nl=False, err=err)
+            # Echo a space to stdout to work around an issue where
+            # readline causes backspace to clear the whole line.
+            value = visible_prompt_func(" ").lower().strip()
+        except (KeyboardInterrupt, EOFError):
+            raise Abort() from None
+        if value in ("y", "yes"):
+            rv = True
+        elif value in ("n", "no"):
+            rv = False
+        elif default is not None and value == "":
+            rv = default
+        else:
+            echo(_("Error: invalid input"), err=err)
+            continue
+        break
+    if abort and not rv:
+        raise Abort()
+    return rv
+
+
+def echo_via_pager(
+    text_or_generator: t.Union[t.Iterable[str], t.Callable[[], t.Iterable[str]], str],
+    color: t.Optional[bool] = None,
+) -> None:
+    """This function takes a text and shows it via an environment specific
+    pager on stdout.
+
+    .. versionchanged:: 3.0
+       Added the `color` flag.
+
+    :param text_or_generator: the text to page, or alternatively, a
+                              generator emitting the text to page.
+    :param color: controls if the pager supports ANSI colors or not.  The
+                  default is autodetection.
+    """
+    color = resolve_color_default(color)
+
+    if inspect.isgeneratorfunction(text_or_generator):
+        i = t.cast(t.Callable[[], t.Iterable[str]], text_or_generator)()
+    elif isinstance(text_or_generator, str):
+        i = [text_or_generator]
+    else:
+        i = iter(t.cast(t.Iterable[str], text_or_generator))
+
+    # convert every element of i to a text type if necessary
+    text_generator = (el if isinstance(el, str) else str(el) for el in i)
+
+    from ._termui_impl import pager
+
+    return pager(itertools.chain(text_generator, "\n"), color)
+
+
+def progressbar(
+    iterable: t.Optional[t.Iterable[V]] = None,
+    length: t.Optional[int] = None,
+    label: t.Optional[str] = None,
+    show_eta: bool = True,
+    show_percent: t.Optional[bool] = None,
+    show_pos: bool = False,
+    item_show_func: t.Optional[t.Callable[[t.Optional[V]], t.Optional[str]]] = None,
+    fill_char: str = "#",
+    empty_char: str = "-",
+    bar_template: str = "%(label)s  [%(bar)s]  %(info)s",
+    info_sep: str = "  ",
+    width: int = 36,
+    file: t.Optional[t.TextIO] = None,
+    color: t.Optional[bool] = None,
+    update_min_steps: int = 1,
+) -> "ProgressBar[V]":
+    """This function creates an iterable context manager that can be used
+    to iterate over something while showing a progress bar.  It will
+    either iterate over the `iterable` or `length` items (that are counted
+    up).  While iteration happens, this function will print a rendered
+    progress bar to the given `file` (defaults to stdout) and will attempt
+    to calculate remaining time and more.  By default, this progress bar
+    will not be rendered if the file is not a terminal.
+
+    The context manager creates the progress bar.  When the context
+    manager is entered the progress bar is already created.  With every
+    iteration over the progress bar, the iterable passed to the bar is
+    advanced and the bar is updated.  When the context manager exits,
+    a newline is printed and the progress bar is finalized on screen.
+
+    Note: The progress bar is currently designed for use cases where the
+    total progress can be expected to take at least several seconds.
+    Because of this, the ProgressBar class object won't display
+    progress that is considered too fast, and progress where the time
+    between steps is less than a second.
+
+    No printing must happen or the progress bar will be unintentionally
+    destroyed.
+
+    Example usage::
+
+        with progressbar(items) as bar:
+            for item in bar:
+                do_something_with(item)
+
+    Alternatively, if no iterable is specified, one can manually update the
+    progress bar through the `update()` method instead of directly
+    iterating over the progress bar.  The update method accepts the number
+    of steps to increment the bar with::
+
+        with progressbar(length=chunks.total_bytes) as bar:
+            for chunk in chunks:
+                process_chunk(chunk)
+                bar.update(chunks.bytes)
+
+    The ``update()`` method also takes an optional value specifying the
+    ``current_item`` at the new position. This is useful when used
+    together with ``item_show_func`` to customize the output for each
+    manual step::
+
+        with click.progressbar(
+            length=total_size,
+            label='Unzipping archive',
+            item_show_func=lambda a: a.filename
+        ) as bar:
+            for archive in zip_file:
+                archive.extract()
+                bar.update(archive.size, archive)
+
+    :param iterable: an iterable to iterate over.  If not provided the length
+                     is required.
+    :param length: the number of items to iterate over.  By default the
+                   progressbar will attempt to ask the iterator about its
+                   length, which might or might not work.  If an iterable is
+                   also provided this parameter can be used to override the
+                   length.  If an iterable is not provided the progress bar
+                   will iterate over a range of that length.
+    :param label: the label to show next to the progress bar.
+    :param show_eta: enables or disables the estimated time display.  This is
+                     automatically disabled if the length cannot be
+                     determined.
+    :param show_percent: enables or disables the percentage display.  The
+                         default is `True` if the iterable has a length or
+                         `False` if not.
+    :param show_pos: enables or disables the absolute position display.  The
+                     default is `False`.
+    :param item_show_func: A function called with the current item which
+        can return a string to show next to the progress bar. If the
+        function returns ``None`` nothing is shown. The current item can
+        be ``None``, such as when entering and exiting the bar.
+    :param fill_char: the character to use to show the filled part of the
+                      progress bar.
+    :param empty_char: the character to use to show the non-filled part of
+                       the progress bar.
+    :param bar_template: the format string to use as template for the bar.
+                         The parameters in it are ``label`` for the label,
+                         ``bar`` for the progress bar and ``info`` for the
+                         info section.
+    :param info_sep: the separator between multiple info items (eta etc.)
+    :param width: the width of the progress bar in characters, 0 means full
+                  terminal width
+    :param file: The file to write to. If this is not a terminal then
+        only the label is printed.
+    :param color: controls if the terminal supports ANSI colors or not.  The
+                  default is autodetection.  This is only needed if ANSI
+                  codes are included anywhere in the progress bar output
+                  which is not the case by default.
+    :param update_min_steps: Render only when this many updates have
+        completed. This allows tuning for very fast iterators.
+
+    .. versionchanged:: 8.0
+        Output is shown even if execution time is less than 0.5 seconds.
+
+    .. versionchanged:: 8.0
+        ``item_show_func`` shows the current item, not the previous one.
+
+    .. versionchanged:: 8.0
+        Labels are echoed if the output is not a TTY. Reverts a change
+        in 7.0 that removed all output.
+
+    .. versionadded:: 8.0
+       Added the ``update_min_steps`` parameter.
+
+    .. versionchanged:: 4.0
+        Added the ``color`` parameter. Added the ``update`` method to
+        the object.
+
+    .. versionadded:: 2.0
+    """
+    from ._termui_impl import ProgressBar
+
+    color = resolve_color_default(color)
+    return ProgressBar(
+        iterable=iterable,
+        length=length,
+        show_eta=show_eta,
+        show_percent=show_percent,
+        show_pos=show_pos,
+        item_show_func=item_show_func,
+        fill_char=fill_char,
+        empty_char=empty_char,
+        bar_template=bar_template,
+        info_sep=info_sep,
+        file=file,
+        label=label,
+        width=width,
+        color=color,
+        update_min_steps=update_min_steps,
+    )
+
+
+def clear() -> None:
+    """Clears the terminal screen.  This will have the effect of clearing
+    the whole visible space of the terminal and moving the cursor to the
+    top left.  This does not do anything if not connected to a terminal.
+
+    .. versionadded:: 2.0
+    """
+    if not isatty(sys.stdout):
+        return
+
+    # ANSI escape \033[2J clears the screen, \033[1;1H moves the cursor
+    echo("\033[2J\033[1;1H", nl=False)
+
+
+def _interpret_color(
+    color: t.Union[int, t.Tuple[int, int, int], str], offset: int = 0
+) -> str:
+    if isinstance(color, int):
+        return f"{38 + offset};5;{color:d}"
+
+    if isinstance(color, (tuple, list)):
+        r, g, b = color
+        return f"{38 + offset};2;{r:d};{g:d};{b:d}"
+
+    return str(_ansi_colors[color] + offset)
+
+
+def style(
+    text: t.Any,
+    fg: t.Optional[t.Union[int, t.Tuple[int, int, int], str]] = None,
+    bg: t.Optional[t.Union[int, t.Tuple[int, int, int], str]] = None,
+    bold: t.Optional[bool] = None,
+    dim: t.Optional[bool] = None,
+    underline: t.Optional[bool] = None,
+    overline: t.Optional[bool] = None,
+    italic: t.Optional[bool] = None,
+    blink: t.Optional[bool] = None,
+    reverse: t.Optional[bool] = None,
+    strikethrough: t.Optional[bool] = None,
+    reset: bool = True,
+) -> str:
+    """Styles a text with ANSI styles and returns the new string.  By
+    default the styling is self contained which means that at the end
+    of the string a reset code is issued.  This can be prevented by
+    passing ``reset=False``.
+
+    Examples::
+
+        click.echo(click.style('Hello World!', fg='green'))
+        click.echo(click.style('ATTENTION!', blink=True))
+        click.echo(click.style('Some things', reverse=True, fg='cyan'))
+        click.echo(click.style('More colors', fg=(255, 12, 128), bg=117))
+
+    Supported color names:
+
+    * ``black`` (might be a gray)
+    * ``red``
+    * ``green``
+    * ``yellow`` (might be an orange)
+    * ``blue``
+    * ``magenta``
+    * ``cyan``
+    * ``white`` (might be light gray)
+    * ``bright_black``
+    * ``bright_red``
+    * ``bright_green``
+    * ``bright_yellow``
+    * ``bright_blue``
+    * ``bright_magenta``
+    * ``bright_cyan``
+    * ``bright_white``
+    * ``reset`` (reset the color code only)
+
+    If the terminal supports it, color may also be specified as:
+
+    -   An integer in the interval [0, 255]. The terminal must support
+        8-bit/256-color mode.
+    -   An RGB tuple of three integers in [0, 255]. The terminal must
+        support 24-bit/true-color mode.
+
+    See https://en.wikipedia.org/wiki/ANSI_color and
+    https://gist.github.com/XVilka/8346728 for more information.
+
+    :param text: the string to style with ansi codes.
+    :param fg: if provided this will become the foreground color.
+    :param bg: if provided this will become the background color.
+    :param bold: if provided this will enable or disable bold mode.
+    :param dim: if provided this will enable or disable dim mode.  This is
+                badly supported.
+    :param underline: if provided this will enable or disable underline.
+    :param overline: if provided this will enable or disable overline.
+    :param italic: if provided this will enable or disable italic.
+    :param blink: if provided this will enable or disable blinking.
+    :param reverse: if provided this will enable or disable inverse
+                    rendering (foreground becomes background and the
+                    other way round).
+    :param strikethrough: if provided this will enable or disable
+        striking through text.
+    :param reset: by default a reset-all code is added at the end of the
+                  string which means that styles do not carry over.  This
+                  can be disabled to compose styles.
+
+    .. versionchanged:: 8.0
+        A non-string ``message`` is converted to a string.
+
+    .. versionchanged:: 8.0
+       Added support for 256 and RGB color codes.
+
+    .. versionchanged:: 8.0
+        Added the ``strikethrough``, ``italic``, and ``overline``
+        parameters.
+
+    .. versionchanged:: 7.0
+        Added support for bright colors.
+
+    .. versionadded:: 2.0
+    """
+    if not isinstance(text, str):
+        text = str(text)
+
+    bits = []
+
+    if fg:
+        try:
+            bits.append(f"\033[{_interpret_color(fg)}m")
+        except KeyError:
+            raise TypeError(f"Unknown color {fg!r}") from None
+
+    if bg:
+        try:
+            bits.append(f"\033[{_interpret_color(bg, 10)}m")
+        except KeyError:
+            raise TypeError(f"Unknown color {bg!r}") from None
+
+    if bold is not None:
+        bits.append(f"\033[{1 if bold else 22}m")
+    if dim is not None:
+        bits.append(f"\033[{2 if dim else 22}m")
+    if underline is not None:
+        bits.append(f"\033[{4 if underline else 24}m")
+    if overline is not None:
+        bits.append(f"\033[{53 if overline else 55}m")
+    if italic is not None:
+        bits.append(f"\033[{3 if italic else 23}m")
+    if blink is not None:
+        bits.append(f"\033[{5 if blink else 25}m")
+    if reverse is not None:
+        bits.append(f"\033[{7 if reverse else 27}m")
+    if strikethrough is not None:
+        bits.append(f"\033[{9 if strikethrough else 29}m")
+    bits.append(text)
+    if reset:
+        bits.append(_ansi_reset_all)
+    return "".join(bits)
+
+
+def unstyle(text: str) -> str:
+    """Removes ANSI styling information from a string.  Usually it's not
+    necessary to use this function as Click's echo function will
+    automatically remove styling if necessary.
+
+    .. versionadded:: 2.0
+
+    :param text: the text to remove style information from.
+    """
+    return strip_ansi(text)
+
+
+def secho(
+    message: t.Optional[t.Any] = None,
+    file: t.Optional[t.IO[t.AnyStr]] = None,
+    nl: bool = True,
+    err: bool = False,
+    color: t.Optional[bool] = None,
+    **styles: t.Any,
+) -> None:
+    """This function combines :func:`echo` and :func:`style` into one
+    call.  As such the following two calls are the same::
+
+        click.secho('Hello World!', fg='green')
+        click.echo(click.style('Hello World!', fg='green'))
+
+    All keyword arguments are forwarded to the underlying functions
+    depending on which one they go with.
+
+    Non-string types will be converted to :class:`str`. However,
+    :class:`bytes` are passed directly to :meth:`echo` without applying
+    style. If you want to style bytes that represent text, call
+    :meth:`bytes.decode` first.
+
+    .. versionchanged:: 8.0
+        A non-string ``message`` is converted to a string. Bytes are
+        passed through without style applied.
+
+    .. versionadded:: 2.0
+    """
+    if message is not None and not isinstance(message, (bytes, bytearray)):
+        message = style(message, **styles)
+
+    return echo(message, file=file, nl=nl, err=err, color=color)
+
+
+def edit(
+    text: t.Optional[t.AnyStr] = None,
+    editor: t.Optional[str] = None,
+    env: t.Optional[t.Mapping[str, str]] = None,
+    require_save: bool = True,
+    extension: str = ".txt",
+    filename: t.Optional[str] = None,
+) -> t.Optional[t.AnyStr]:
+    r"""Edits the given text in the defined editor.  If an editor is given
+    (should be the full path to the executable but the regular operating
+    system search path is used for finding the executable) it overrides
+    the detected editor.  Optionally, some environment variables can be
+    used.  If the editor is closed without changes, `None` is returned.  In
+    case a file is edited directly the return value is always `None` and
+    `require_save` and `extension` are ignored.
+
+    If the editor cannot be opened a :exc:`UsageError` is raised.
+
+    Note for Windows: to simplify cross-platform usage, the newlines are
+    automatically converted from POSIX to Windows and vice versa.  As such,
+    the message here will have ``\n`` as newline markers.
+
+    :param text: the text to edit.
+    :param editor: optionally the editor to use.  Defaults to automatic
+                   detection.
+    :param env: environment variables to forward to the editor.
+    :param require_save: if this is true, then not saving in the editor
+                         will make the return value become `None`.
+    :param extension: the extension to tell the editor about.  This defaults
+                      to `.txt` but changing this might change syntax
+                      highlighting.
+    :param filename: if provided it will edit this file instead of the
+                     provided text contents.  It will not use a temporary
+                     file as an indirection in that case.
+    """
+    from ._termui_impl import Editor
+
+    ed = Editor(editor=editor, env=env, require_save=require_save, extension=extension)
+
+    if filename is None:
+        return ed.edit(text)
+
+    ed.edit_file(filename)
+    return None
+
+
+def launch(url: str, wait: bool = False, locate: bool = False) -> int:
+    """This function launches the given URL (or filename) in the default
+    viewer application for this file type.  If this is an executable, it
+    might launch the executable in a new session.  The return value is
+    the exit code of the launched application.  Usually, ``0`` indicates
+    success.
+
+    Examples::
+
+        click.launch('https://click.palletsprojects.com/')
+        click.launch('/my/downloaded/file', locate=True)
+
+    .. versionadded:: 2.0
+
+    :param url: URL or filename of the thing to launch.
+    :param wait: Wait for the program to exit before returning. This
+        only works if the launched program blocks. In particular,
+        ``xdg-open`` on Linux does not block.
+    :param locate: if this is set to `True` then instead of launching the
+                   application associated with the URL it will attempt to
+                   launch a file manager with the file located.  This
+                   might have weird effects if the URL does not point to
+                   the filesystem.
+    """
+    from ._termui_impl import open_url
+
+    return open_url(url, wait=wait, locate=locate)
+
+
+# If this is provided, getchar() calls into this instead.  This is used
+# for unittesting purposes.
+_getchar: t.Optional[t.Callable[[bool], str]] = None
+
+
+def getchar(echo: bool = False) -> str:
+    """Fetches a single character from the terminal and returns it.  This
+    will always return a unicode character and under certain rare
+    circumstances this might return more than one character.  The
+    situations which more than one character is returned is when for
+    whatever reason multiple characters end up in the terminal buffer or
+    standard input was not actually a terminal.
+
+    Note that this will always read from the terminal, even if something
+    is piped into the standard input.
+
+    Note for Windows: in rare cases when typing non-ASCII characters, this
+    function might wait for a second character and then return both at once.
+    This is because certain Unicode characters look like special-key markers.
+
+    .. versionadded:: 2.0
+
+    :param echo: if set to `True`, the character read will also show up on
+                 the terminal.  The default is to not show it.
+    """
+    global _getchar
+
+    if _getchar is None:
+        from ._termui_impl import getchar as f
+
+        _getchar = f
+
+    return _getchar(echo)
+
+
+def raw_terminal() -> t.ContextManager[int]:
+    from ._termui_impl import raw_terminal as f
+
+    return f()
+
+
+def pause(info: t.Optional[str] = None, err: bool = False) -> None:
+    """This command stops execution and waits for the user to press any
+    key to continue.  This is similar to the Windows batch "pause"
+    command.  If the program is not run through a terminal, this command
+    will instead do nothing.
+
+    .. versionadded:: 2.0
+
+    .. versionadded:: 4.0
+       Added the `err` parameter.
+
+    :param info: The message to print before pausing. Defaults to
+        ``"Press any key to continue..."``.
+    :param err: if set to message goes to ``stderr`` instead of
+                ``stdout``, the same as with echo.
+    """
+    if not isatty(sys.stdin) or not isatty(sys.stdout):
+        return
+
+    if info is None:
+        info = _("Press any key to continue...")
+
+    try:
+        if info:
+            echo(info, nl=False, err=err)
+        try:
+            getchar()
+        except (KeyboardInterrupt, EOFError):
+            pass
+    finally:
+        if info:
+            echo(err=err)

+ 483 - 0
venv/lib/python3.9/site-packages/click/testing.py

@@ -0,0 +1,483 @@
+import contextlib
+import io
+import os
+import shlex
+import shutil
+import sys
+import tempfile
+import typing as t
+from types import TracebackType
+
+from . import _compat
+from . import formatting
+from . import termui
+from . import utils
+from ._compat import _find_binary_reader
+
+if t.TYPE_CHECKING:
+    from .core import BaseCommand
+
+
+class EchoingStdin:
+    def __init__(self, input: t.BinaryIO, output: t.BinaryIO) -> None:
+        self._input = input
+        self._output = output
+        self._paused = False
+
+    def __getattr__(self, x: str) -> t.Any:
+        return getattr(self._input, x)
+
+    def _echo(self, rv: bytes) -> bytes:
+        if not self._paused:
+            self._output.write(rv)
+
+        return rv
+
+    def read(self, n: int = -1) -> bytes:
+        return self._echo(self._input.read(n))
+
+    def read1(self, n: int = -1) -> bytes:
+        return self._echo(self._input.read1(n))  # type: ignore
+
+    def readline(self, n: int = -1) -> bytes:
+        return self._echo(self._input.readline(n))
+
+    def readlines(self) -> t.List[bytes]:
+        return [self._echo(x) for x in self._input.readlines()]
+
+    def __iter__(self) -> t.Iterator[bytes]:
+        return iter(self._echo(x) for x in self._input)
+
+    def __repr__(self) -> str:
+        return repr(self._input)
+
+
+@contextlib.contextmanager
+def _pause_echo(stream: t.Optional[EchoingStdin]) -> t.Iterator[None]:
+    if stream is None:
+        yield
+    else:
+        stream._paused = True
+        yield
+        stream._paused = False
+
+
+class _NamedTextIOWrapper(io.TextIOWrapper):
+    def __init__(
+        self, buffer: t.BinaryIO, name: str, mode: str, **kwargs: t.Any
+    ) -> None:
+        super().__init__(buffer, **kwargs)
+        self._name = name
+        self._mode = mode
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @property
+    def mode(self) -> str:
+        return self._mode
+
+
+def make_input_stream(
+    input: t.Optional[t.Union[str, bytes, t.IO[t.Any]]], charset: str
+) -> t.BinaryIO:
+    # Is already an input stream.
+    if hasattr(input, "read"):
+        rv = _find_binary_reader(t.cast(t.IO[t.Any], input))
+
+        if rv is not None:
+            return rv
+
+        raise TypeError("Could not find binary reader for input stream.")
+
+    if input is None:
+        input = b""
+    elif isinstance(input, str):
+        input = input.encode(charset)
+
+    return io.BytesIO(input)
+
+
+class Result:
+    """Holds the captured result of an invoked CLI script."""
+
+    def __init__(
+        self,
+        runner: "CliRunner",
+        stdout_bytes: bytes,
+        stderr_bytes: t.Optional[bytes],
+        return_value: t.Any,
+        exit_code: int,
+        exception: t.Optional[BaseException],
+        exc_info: t.Optional[
+            t.Tuple[t.Type[BaseException], BaseException, TracebackType]
+        ] = None,
+    ):
+        #: The runner that created the result
+        self.runner = runner
+        #: The standard output as bytes.
+        self.stdout_bytes = stdout_bytes
+        #: The standard error as bytes, or None if not available
+        self.stderr_bytes = stderr_bytes
+        #: The value returned from the invoked command.
+        #:
+        #: .. versionadded:: 8.0
+        self.return_value = return_value
+        #: The exit code as integer.
+        self.exit_code = exit_code
+        #: The exception that happened if one did.
+        self.exception = exception
+        #: The traceback
+        self.exc_info = exc_info
+
+    @property
+    def output(self) -> str:
+        """The (standard) output as unicode string."""
+        return self.stdout
+
+    @property
+    def stdout(self) -> str:
+        """The standard output as unicode string."""
+        return self.stdout_bytes.decode(self.runner.charset, "replace").replace(
+            "\r\n", "\n"
+        )
+
+    @property
+    def stderr(self) -> str:
+        """The standard error as unicode string."""
+        if self.stderr_bytes is None:
+            raise ValueError("stderr not separately captured")
+        return self.stderr_bytes.decode(self.runner.charset, "replace").replace(
+            "\r\n", "\n"
+        )
+
+    def __repr__(self) -> str:
+        exc_str = repr(self.exception) if self.exception else "okay"
+        return f"<{type(self).__name__} {exc_str}>"
+
+
+class CliRunner:
+    """The CLI runner provides functionality to invoke a Click command line
+    script for unittesting purposes in a isolated environment.  This only
+    works in single-threaded systems without any concurrency as it changes the
+    global interpreter state.
+
+    :param charset: the character set for the input and output data.
+    :param env: a dictionary with environment variables for overriding.
+    :param echo_stdin: if this is set to `True`, then reading from stdin writes
+                       to stdout.  This is useful for showing examples in
+                       some circumstances.  Note that regular prompts
+                       will automatically echo the input.
+    :param mix_stderr: if this is set to `False`, then stdout and stderr are
+                       preserved as independent streams.  This is useful for
+                       Unix-philosophy apps that have predictable stdout and
+                       noisy stderr, such that each may be measured
+                       independently
+    """
+
+    def __init__(
+        self,
+        charset: str = "utf-8",
+        env: t.Optional[t.Mapping[str, t.Optional[str]]] = None,
+        echo_stdin: bool = False,
+        mix_stderr: bool = True,
+    ) -> None:
+        self.charset = charset
+        self.env: t.Mapping[str, t.Optional[str]] = env or {}
+        self.echo_stdin = echo_stdin
+        self.mix_stderr = mix_stderr
+
+    def get_default_prog_name(self, cli: "BaseCommand") -> str:
+        """Given a command object it will return the default program name
+        for it.  The default is the `name` attribute or ``"root"`` if not
+        set.
+        """
+        return cli.name or "root"
+
+    def make_env(
+        self, overrides: t.Optional[t.Mapping[str, t.Optional[str]]] = None
+    ) -> t.Mapping[str, t.Optional[str]]:
+        """Returns the environment overrides for invoking a script."""
+        rv = dict(self.env)
+        if overrides:
+            rv.update(overrides)
+        return rv
+
+    @contextlib.contextmanager
+    def isolation(
+        self,
+        input: t.Optional[t.Union[str, bytes, t.IO[t.Any]]] = None,
+        env: t.Optional[t.Mapping[str, t.Optional[str]]] = None,
+        color: bool = False,
+    ) -> t.Iterator[t.Tuple[io.BytesIO, t.Optional[io.BytesIO]]]:
+        """A context manager that sets up the isolation for invoking of a
+        command line tool.  This sets up stdin with the given input data
+        and `os.environ` with the overrides from the given dictionary.
+        This also rebinds some internals in Click to be mocked (like the
+        prompt functionality).
+
+        This is automatically done in the :meth:`invoke` method.
+
+        :param input: the input stream to put into sys.stdin.
+        :param env: the environment overrides as dictionary.
+        :param color: whether the output should contain color codes. The
+                      application can still override this explicitly.
+
+        .. versionchanged:: 8.0
+            ``stderr`` is opened with ``errors="backslashreplace"``
+            instead of the default ``"strict"``.
+
+        .. versionchanged:: 4.0
+            Added the ``color`` parameter.
+        """
+        bytes_input = make_input_stream(input, self.charset)
+        echo_input = None
+
+        old_stdin = sys.stdin
+        old_stdout = sys.stdout
+        old_stderr = sys.stderr
+        old_forced_width = formatting.FORCED_WIDTH
+        formatting.FORCED_WIDTH = 80
+
+        env = self.make_env(env)
+
+        bytes_output = io.BytesIO()
+
+        if self.echo_stdin:
+            bytes_input = echo_input = t.cast(
+                t.BinaryIO, EchoingStdin(bytes_input, bytes_output)
+            )
+
+        sys.stdin = text_input = _NamedTextIOWrapper(
+            bytes_input, encoding=self.charset, name="<stdin>", mode="r"
+        )
+
+        if self.echo_stdin:
+            # Force unbuffered reads, otherwise TextIOWrapper reads a
+            # large chunk which is echoed early.
+            text_input._CHUNK_SIZE = 1  # type: ignore
+
+        sys.stdout = _NamedTextIOWrapper(
+            bytes_output, encoding=self.charset, name="<stdout>", mode="w"
+        )
+
+        bytes_error = None
+        if self.mix_stderr:
+            sys.stderr = sys.stdout
+        else:
+            bytes_error = io.BytesIO()
+            sys.stderr = _NamedTextIOWrapper(
+                bytes_error,
+                encoding=self.charset,
+                name="<stderr>",
+                mode="w",
+                errors="backslashreplace",
+            )
+
+        @_pause_echo(echo_input)  # type: ignore
+        def visible_input(prompt: t.Optional[str] = None) -> str:
+            sys.stdout.write(prompt or "")
+            val = text_input.readline().rstrip("\r\n")
+            sys.stdout.write(f"{val}\n")
+            sys.stdout.flush()
+            return val
+
+        @_pause_echo(echo_input)  # type: ignore
+        def hidden_input(prompt: t.Optional[str] = None) -> str:
+            sys.stdout.write(f"{prompt or ''}\n")
+            sys.stdout.flush()
+            return text_input.readline().rstrip("\r\n")
+
+        @_pause_echo(echo_input)  # type: ignore
+        def _getchar(echo: bool) -> str:
+            char = sys.stdin.read(1)
+
+            if echo:
+                sys.stdout.write(char)
+
+            sys.stdout.flush()
+            return char
+
+        default_color = color
+
+        def should_strip_ansi(
+            stream: t.Optional[t.IO[t.Any]] = None, color: t.Optional[bool] = None
+        ) -> bool:
+            if color is None:
+                return not default_color
+            return not color
+
+        old_visible_prompt_func = termui.visible_prompt_func
+        old_hidden_prompt_func = termui.hidden_prompt_func
+        old__getchar_func = termui._getchar
+        old_should_strip_ansi = utils.should_strip_ansi  # type: ignore
+        old__compat_should_strip_ansi = _compat.should_strip_ansi
+        termui.visible_prompt_func = visible_input
+        termui.hidden_prompt_func = hidden_input
+        termui._getchar = _getchar
+        utils.should_strip_ansi = should_strip_ansi  # type: ignore
+        _compat.should_strip_ansi = should_strip_ansi
+
+        old_env = {}
+        try:
+            for key, value in env.items():
+                old_env[key] = os.environ.get(key)
+                if value is None:
+                    try:
+                        del os.environ[key]
+                    except Exception:
+                        pass
+                else:
+                    os.environ[key] = value
+            yield (bytes_output, bytes_error)
+        finally:
+            for key, value in old_env.items():
+                if value is None:
+                    try:
+                        del os.environ[key]
+                    except Exception:
+                        pass
+                else:
+                    os.environ[key] = value
+            sys.stdout = old_stdout
+            sys.stderr = old_stderr
+            sys.stdin = old_stdin
+            termui.visible_prompt_func = old_visible_prompt_func
+            termui.hidden_prompt_func = old_hidden_prompt_func
+            termui._getchar = old__getchar_func
+            utils.should_strip_ansi = old_should_strip_ansi  # type: ignore
+            _compat.should_strip_ansi = old__compat_should_strip_ansi
+            formatting.FORCED_WIDTH = old_forced_width
+
+    def invoke(
+        self,
+        cli: "BaseCommand",
+        args: t.Optional[t.Union[str, t.Sequence[str]]] = None,
+        input: t.Optional[t.Union[str, bytes, t.IO[t.Any]]] = None,
+        env: t.Optional[t.Mapping[str, t.Optional[str]]] = None,
+        catch_exceptions: bool = True,
+        color: bool = False,
+        **extra: t.Any,
+    ) -> Result:
+        """Invokes a command in an isolated environment.  The arguments are
+        forwarded directly to the command line script, the `extra` keyword
+        arguments are passed to the :meth:`~clickpkg.Command.main` function of
+        the command.
+
+        This returns a :class:`Result` object.
+
+        :param cli: the command to invoke
+        :param args: the arguments to invoke. It may be given as an iterable
+                     or a string. When given as string it will be interpreted
+                     as a Unix shell command. More details at
+                     :func:`shlex.split`.
+        :param input: the input data for `sys.stdin`.
+        :param env: the environment overrides.
+        :param catch_exceptions: Whether to catch any other exceptions than
+                                 ``SystemExit``.
+        :param extra: the keyword arguments to pass to :meth:`main`.
+        :param color: whether the output should contain color codes. The
+                      application can still override this explicitly.
+
+        .. versionchanged:: 8.0
+            The result object has the ``return_value`` attribute with
+            the value returned from the invoked command.
+
+        .. versionchanged:: 4.0
+            Added the ``color`` parameter.
+
+        .. versionchanged:: 3.0
+            Added the ``catch_exceptions`` parameter.
+
+        .. versionchanged:: 3.0
+            The result object has the ``exc_info`` attribute with the
+            traceback if available.
+        """
+        exc_info = None
+        with self.isolation(input=input, env=env, color=color) as outstreams:
+            return_value = None
+            exception: t.Optional[BaseException] = None
+            exit_code = 0
+
+            if isinstance(args, str):
+                args = shlex.split(args)
+
+            try:
+                prog_name = extra.pop("prog_name")
+            except KeyError:
+                prog_name = self.get_default_prog_name(cli)
+
+            try:
+                return_value = cli.main(args=args or (), prog_name=prog_name, **extra)
+            except SystemExit as e:
+                exc_info = sys.exc_info()
+                e_code = t.cast(t.Optional[t.Union[int, t.Any]], e.code)
+
+                if e_code is None:
+                    e_code = 0
+
+                if e_code != 0:
+                    exception = e
+
+                if not isinstance(e_code, int):
+                    sys.stdout.write(str(e_code))
+                    sys.stdout.write("\n")
+                    e_code = 1
+
+                exit_code = e_code
+
+            except Exception as e:
+                if not catch_exceptions:
+                    raise
+                exception = e
+                exit_code = 1
+                exc_info = sys.exc_info()
+            finally:
+                sys.stdout.flush()
+                stdout = outstreams[0].getvalue()
+                if self.mix_stderr:
+                    stderr = None
+                else:
+                    stderr = outstreams[1].getvalue()  # type: ignore
+
+        return Result(
+            runner=self,
+            stdout_bytes=stdout,
+            stderr_bytes=stderr,
+            return_value=return_value,
+            exit_code=exit_code,
+            exception=exception,
+            exc_info=exc_info,  # type: ignore
+        )
+
+    @contextlib.contextmanager
+    def isolated_filesystem(
+        self, temp_dir: t.Optional[t.Union[str, "os.PathLike[str]"]] = None
+    ) -> t.Iterator[str]:
+        """A context manager that creates a temporary directory and
+        changes the current working directory to it. This isolates tests
+        that affect the contents of the CWD to prevent them from
+        interfering with each other.
+
+        :param temp_dir: Create the temporary directory under this
+            directory. If given, the created directory is not removed
+            when exiting.
+
+        .. versionchanged:: 8.0
+            Added the ``temp_dir`` parameter.
+        """
+        cwd = os.getcwd()
+        dt = tempfile.mkdtemp(dir=temp_dir)
+        os.chdir(dt)
+
+        try:
+            yield dt
+        finally:
+            os.chdir(cwd)
+
+            if temp_dir is None:
+                try:
+                    shutil.rmtree(dt)
+                except OSError:
+                    pass

+ 1093 - 0
venv/lib/python3.9/site-packages/click/types.py

@@ -0,0 +1,1093 @@
+import os
+import stat
+import sys
+import typing as t
+from datetime import datetime
+from gettext import gettext as _
+from gettext import ngettext
+
+from ._compat import _get_argv_encoding
+from ._compat import open_stream
+from .exceptions import BadParameter
+from .utils import format_filename
+from .utils import LazyFile
+from .utils import safecall
+
+if t.TYPE_CHECKING:
+    import typing_extensions as te
+
+    from .core import Context
+    from .core import Parameter
+    from .shell_completion import CompletionItem
+
+
+class ParamType:
+    """Represents the type of a parameter. Validates and converts values
+    from the command line or Python into the correct type.
+
+    To implement a custom type, subclass and implement at least the
+    following:
+
+    -   The :attr:`name` class attribute must be set.
+    -   Calling an instance of the type with ``None`` must return
+        ``None``. This is already implemented by default.
+    -   :meth:`convert` must convert string values to the correct type.
+    -   :meth:`convert` must accept values that are already the correct
+        type.
+    -   It must be able to convert a value if the ``ctx`` and ``param``
+        arguments are ``None``. This can occur when converting prompt
+        input.
+    """
+
+    is_composite: t.ClassVar[bool] = False
+    arity: t.ClassVar[int] = 1
+
+    #: the descriptive name of this type
+    name: str
+
+    #: if a list of this type is expected and the value is pulled from a
+    #: string environment variable, this is what splits it up.  `None`
+    #: means any whitespace.  For all parameters the general rule is that
+    #: whitespace splits them up.  The exception are paths and files which
+    #: are split by ``os.path.pathsep`` by default (":" on Unix and ";" on
+    #: Windows).
+    envvar_list_splitter: t.ClassVar[t.Optional[str]] = None
+
+    def to_info_dict(self) -> t.Dict[str, t.Any]:
+        """Gather information that could be useful for a tool generating
+        user-facing documentation.
+
+        Use :meth:`click.Context.to_info_dict` to traverse the entire
+        CLI structure.
+
+        .. versionadded:: 8.0
+        """
+        # The class name without the "ParamType" suffix.
+        param_type = type(self).__name__.partition("ParamType")[0]
+        param_type = param_type.partition("ParameterType")[0]
+
+        # Custom subclasses might not remember to set a name.
+        if hasattr(self, "name"):
+            name = self.name
+        else:
+            name = param_type
+
+        return {"param_type": param_type, "name": name}
+
+    def __call__(
+        self,
+        value: t.Any,
+        param: t.Optional["Parameter"] = None,
+        ctx: t.Optional["Context"] = None,
+    ) -> t.Any:
+        if value is not None:
+            return self.convert(value, param, ctx)
+
+    def get_metavar(self, param: "Parameter") -> t.Optional[str]:
+        """Returns the metavar default for this param if it provides one."""
+
+    def get_missing_message(self, param: "Parameter") -> t.Optional[str]:
+        """Optionally might return extra information about a missing
+        parameter.
+
+        .. versionadded:: 2.0
+        """
+
+    def convert(
+        self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"]
+    ) -> t.Any:
+        """Convert the value to the correct type. This is not called if
+        the value is ``None`` (the missing value).
+
+        This must accept string values from the command line, as well as
+        values that are already the correct type. It may also convert
+        other compatible types.
+
+        The ``param`` and ``ctx`` arguments may be ``None`` in certain
+        situations, such as when converting prompt input.
+
+        If the value cannot be converted, call :meth:`fail` with a
+        descriptive message.
+
+        :param value: The value to convert.
+        :param param: The parameter that is using this type to convert
+            its value. May be ``None``.
+        :param ctx: The current context that arrived at this value. May
+            be ``None``.
+        """
+        return value
+
+    def split_envvar_value(self, rv: str) -> t.Sequence[str]:
+        """Given a value from an environment variable this splits it up
+        into small chunks depending on the defined envvar list splitter.
+
+        If the splitter is set to `None`, which means that whitespace splits,
+        then leading and trailing whitespace is ignored.  Otherwise, leading
+        and trailing splitters usually lead to empty items being included.
+        """
+        return (rv or "").split(self.envvar_list_splitter)
+
+    def fail(
+        self,
+        message: str,
+        param: t.Optional["Parameter"] = None,
+        ctx: t.Optional["Context"] = None,
+    ) -> "t.NoReturn":
+        """Helper method to fail with an invalid value message."""
+        raise BadParameter(message, ctx=ctx, param=param)
+
+    def shell_complete(
+        self, ctx: "Context", param: "Parameter", incomplete: str
+    ) -> t.List["CompletionItem"]:
+        """Return a list of
+        :class:`~click.shell_completion.CompletionItem` objects for the
+        incomplete value. Most types do not provide completions, but
+        some do, and this allows custom types to provide custom
+        completions as well.
+
+        :param ctx: Invocation context for this command.
+        :param param: The parameter that is requesting completion.
+        :param incomplete: Value being completed. May be empty.
+
+        .. versionadded:: 8.0
+        """
+        return []
+
+
+class CompositeParamType(ParamType):
+    is_composite = True
+
+    @property
+    def arity(self) -> int:  # type: ignore
+        raise NotImplementedError()
+
+
+class FuncParamType(ParamType):
+    def __init__(self, func: t.Callable[[t.Any], t.Any]) -> None:
+        self.name: str = func.__name__
+        self.func = func
+
+    def to_info_dict(self) -> t.Dict[str, t.Any]:
+        info_dict = super().to_info_dict()
+        info_dict["func"] = self.func
+        return info_dict
+
+    def convert(
+        self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"]
+    ) -> t.Any:
+        try:
+            return self.func(value)
+        except ValueError:
+            try:
+                value = str(value)
+            except UnicodeError:
+                value = value.decode("utf-8", "replace")
+
+            self.fail(value, param, ctx)
+
+
+class UnprocessedParamType(ParamType):
+    name = "text"
+
+    def convert(
+        self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"]
+    ) -> t.Any:
+        return value
+
+    def __repr__(self) -> str:
+        return "UNPROCESSED"
+
+
+class StringParamType(ParamType):
+    name = "text"
+
+    def convert(
+        self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"]
+    ) -> t.Any:
+        if isinstance(value, bytes):
+            enc = _get_argv_encoding()
+            try:
+                value = value.decode(enc)
+            except UnicodeError:
+                fs_enc = sys.getfilesystemencoding()
+                if fs_enc != enc:
+                    try:
+                        value = value.decode(fs_enc)
+                    except UnicodeError:
+                        value = value.decode("utf-8", "replace")
+                else:
+                    value = value.decode("utf-8", "replace")
+            return value
+        return str(value)
+
+    def __repr__(self) -> str:
+        return "STRING"
+
+
+class Choice(ParamType):
+    """The choice type allows a value to be checked against a fixed set
+    of supported values. All of these values have to be strings.
+
+    You should only pass a list or tuple of choices. Other iterables
+    (like generators) may lead to surprising results.
+
+    The resulting value will always be one of the originally passed choices
+    regardless of ``case_sensitive`` or any ``ctx.token_normalize_func``
+    being specified.
+
+    See :ref:`choice-opts` for an example.
+
+    :param case_sensitive: Set to false to make choices case
+        insensitive. Defaults to true.
+    """
+
+    name = "choice"
+
+    def __init__(self, choices: t.Sequence[str], case_sensitive: bool = True) -> None:
+        self.choices = choices
+        self.case_sensitive = case_sensitive
+
+    def to_info_dict(self) -> t.Dict[str, t.Any]:
+        info_dict = super().to_info_dict()
+        info_dict["choices"] = self.choices
+        info_dict["case_sensitive"] = self.case_sensitive
+        return info_dict
+
+    def get_metavar(self, param: "Parameter") -> str:
+        choices_str = "|".join(self.choices)
+
+        # Use curly braces to indicate a required argument.
+        if param.required and param.param_type_name == "argument":
+            return f"{{{choices_str}}}"
+
+        # Use square braces to indicate an option or optional argument.
+        return f"[{choices_str}]"
+
+    def get_missing_message(self, param: "Parameter") -> str:
+        return _("Choose from:\n\t{choices}").format(choices=",\n\t".join(self.choices))
+
+    def convert(
+        self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"]
+    ) -> t.Any:
+        # Match through normalization and case sensitivity
+        # first do token_normalize_func, then lowercase
+        # preserve original `value` to produce an accurate message in
+        # `self.fail`
+        normed_value = value
+        normed_choices = {choice: choice for choice in self.choices}
+
+        if ctx is not None and ctx.token_normalize_func is not None:
+            normed_value = ctx.token_normalize_func(value)
+            normed_choices = {
+                ctx.token_normalize_func(normed_choice): original
+                for normed_choice, original in normed_choices.items()
+            }
+
+        if not self.case_sensitive:
+            normed_value = normed_value.casefold()
+            normed_choices = {
+                normed_choice.casefold(): original
+                for normed_choice, original in normed_choices.items()
+            }
+
+        if normed_value in normed_choices:
+            return normed_choices[normed_value]
+
+        choices_str = ", ".join(map(repr, self.choices))
+        self.fail(
+            ngettext(
+                "{value!r} is not {choice}.",
+                "{value!r} is not one of {choices}.",
+                len(self.choices),
+            ).format(value=value, choice=choices_str, choices=choices_str),
+            param,
+            ctx,
+        )
+
+    def __repr__(self) -> str:
+        return f"Choice({list(self.choices)})"
+
+    def shell_complete(
+        self, ctx: "Context", param: "Parameter", incomplete: str
+    ) -> t.List["CompletionItem"]:
+        """Complete choices that start with the incomplete value.
+
+        :param ctx: Invocation context for this command.
+        :param param: The parameter that is requesting completion.
+        :param incomplete: Value being completed. May be empty.
+
+        .. versionadded:: 8.0
+        """
+        from click.shell_completion import CompletionItem
+
+        str_choices = map(str, self.choices)
+
+        if self.case_sensitive:
+            matched = (c for c in str_choices if c.startswith(incomplete))
+        else:
+            incomplete = incomplete.lower()
+            matched = (c for c in str_choices if c.lower().startswith(incomplete))
+
+        return [CompletionItem(c) for c in matched]
+
+
+class DateTime(ParamType):
+    """The DateTime type converts date strings into `datetime` objects.
+
+    The format strings which are checked are configurable, but default to some
+    common (non-timezone aware) ISO 8601 formats.
+
+    When specifying *DateTime* formats, you should only pass a list or a tuple.
+    Other iterables, like generators, may lead to surprising results.
+
+    The format strings are processed using ``datetime.strptime``, and this
+    consequently defines the format strings which are allowed.
+
+    Parsing is tried using each format, in order, and the first format which
+    parses successfully is used.
+
+    :param formats: A list or tuple of date format strings, in the order in
+                    which they should be tried. Defaults to
+                    ``'%Y-%m-%d'``, ``'%Y-%m-%dT%H:%M:%S'``,
+                    ``'%Y-%m-%d %H:%M:%S'``.
+    """
+
+    name = "datetime"
+
+    def __init__(self, formats: t.Optional[t.Sequence[str]] = None):
+        self.formats: t.Sequence[str] = formats or [
+            "%Y-%m-%d",
+            "%Y-%m-%dT%H:%M:%S",
+            "%Y-%m-%d %H:%M:%S",
+        ]
+
+    def to_info_dict(self) -> t.Dict[str, t.Any]:
+        info_dict = super().to_info_dict()
+        info_dict["formats"] = self.formats
+        return info_dict
+
+    def get_metavar(self, param: "Parameter") -> str:
+        return f"[{'|'.join(self.formats)}]"
+
+    def _try_to_convert_date(self, value: t.Any, format: str) -> t.Optional[datetime]:
+        try:
+            return datetime.strptime(value, format)
+        except ValueError:
+            return None
+
+    def convert(
+        self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"]
+    ) -> t.Any:
+        if isinstance(value, datetime):
+            return value
+
+        for format in self.formats:
+            converted = self._try_to_convert_date(value, format)
+
+            if converted is not None:
+                return converted
+
+        formats_str = ", ".join(map(repr, self.formats))
+        self.fail(
+            ngettext(
+                "{value!r} does not match the format {format}.",
+                "{value!r} does not match the formats {formats}.",
+                len(self.formats),
+            ).format(value=value, format=formats_str, formats=formats_str),
+            param,
+            ctx,
+        )
+
+    def __repr__(self) -> str:
+        return "DateTime"
+
+
+class _NumberParamTypeBase(ParamType):
+    _number_class: t.ClassVar[t.Type[t.Any]]
+
+    def convert(
+        self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"]
+    ) -> t.Any:
+        try:
+            return self._number_class(value)
+        except ValueError:
+            self.fail(
+                _("{value!r} is not a valid {number_type}.").format(
+                    value=value, number_type=self.name
+                ),
+                param,
+                ctx,
+            )
+
+
+class _NumberRangeBase(_NumberParamTypeBase):
+    def __init__(
+        self,
+        min: t.Optional[float] = None,
+        max: t.Optional[float] = None,
+        min_open: bool = False,
+        max_open: bool = False,
+        clamp: bool = False,
+    ) -> None:
+        self.min = min
+        self.max = max
+        self.min_open = min_open
+        self.max_open = max_open
+        self.clamp = clamp
+
+    def to_info_dict(self) -> t.Dict[str, t.Any]:
+        info_dict = super().to_info_dict()
+        info_dict.update(
+            min=self.min,
+            max=self.max,
+            min_open=self.min_open,
+            max_open=self.max_open,
+            clamp=self.clamp,
+        )
+        return info_dict
+
+    def convert(
+        self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"]
+    ) -> t.Any:
+        import operator
+
+        rv = super().convert(value, param, ctx)
+        lt_min: bool = self.min is not None and (
+            operator.le if self.min_open else operator.lt
+        )(rv, self.min)
+        gt_max: bool = self.max is not None and (
+            operator.ge if self.max_open else operator.gt
+        )(rv, self.max)
+
+        if self.clamp:
+            if lt_min:
+                return self._clamp(self.min, 1, self.min_open)  # type: ignore
+
+            if gt_max:
+                return self._clamp(self.max, -1, self.max_open)  # type: ignore
+
+        if lt_min or gt_max:
+            self.fail(
+                _("{value} is not in the range {range}.").format(
+                    value=rv, range=self._describe_range()
+                ),
+                param,
+                ctx,
+            )
+
+        return rv
+
+    def _clamp(self, bound: float, dir: "te.Literal[1, -1]", open: bool) -> float:
+        """Find the valid value to clamp to bound in the given
+        direction.
+
+        :param bound: The boundary value.
+        :param dir: 1 or -1 indicating the direction to move.
+        :param open: If true, the range does not include the bound.
+        """
+        raise NotImplementedError
+
+    def _describe_range(self) -> str:
+        """Describe the range for use in help text."""
+        if self.min is None:
+            op = "<" if self.max_open else "<="
+            return f"x{op}{self.max}"
+
+        if self.max is None:
+            op = ">" if self.min_open else ">="
+            return f"x{op}{self.min}"
+
+        lop = "<" if self.min_open else "<="
+        rop = "<" if self.max_open else "<="
+        return f"{self.min}{lop}x{rop}{self.max}"
+
+    def __repr__(self) -> str:
+        clamp = " clamped" if self.clamp else ""
+        return f"<{type(self).__name__} {self._describe_range()}{clamp}>"
+
+
+class IntParamType(_NumberParamTypeBase):
+    name = "integer"
+    _number_class = int
+
+    def __repr__(self) -> str:
+        return "INT"
+
+
+class IntRange(_NumberRangeBase, IntParamType):
+    """Restrict an :data:`click.INT` value to a range of accepted
+    values. See :ref:`ranges`.
+
+    If ``min`` or ``max`` are not passed, any value is accepted in that
+    direction. If ``min_open`` or ``max_open`` are enabled, the
+    corresponding boundary is not included in the range.
+
+    If ``clamp`` is enabled, a value outside the range is clamped to the
+    boundary instead of failing.
+
+    .. versionchanged:: 8.0
+        Added the ``min_open`` and ``max_open`` parameters.
+    """
+
+    name = "integer range"
+
+    def _clamp(  # type: ignore
+        self, bound: int, dir: "te.Literal[1, -1]", open: bool
+    ) -> int:
+        if not open:
+            return bound
+
+        return bound + dir
+
+
+class FloatParamType(_NumberParamTypeBase):
+    name = "float"
+    _number_class = float
+
+    def __repr__(self) -> str:
+        return "FLOAT"
+
+
+class FloatRange(_NumberRangeBase, FloatParamType):
+    """Restrict a :data:`click.FLOAT` value to a range of accepted
+    values. See :ref:`ranges`.
+
+    If ``min`` or ``max`` are not passed, any value is accepted in that
+    direction. If ``min_open`` or ``max_open`` are enabled, the
+    corresponding boundary is not included in the range.
+
+    If ``clamp`` is enabled, a value outside the range is clamped to the
+    boundary instead of failing. This is not supported if either
+    boundary is marked ``open``.
+
+    .. versionchanged:: 8.0
+        Added the ``min_open`` and ``max_open`` parameters.
+    """
+
+    name = "float range"
+
+    def __init__(
+        self,
+        min: t.Optional[float] = None,
+        max: t.Optional[float] = None,
+        min_open: bool = False,
+        max_open: bool = False,
+        clamp: bool = False,
+    ) -> None:
+        super().__init__(
+            min=min, max=max, min_open=min_open, max_open=max_open, clamp=clamp
+        )
+
+        if (min_open or max_open) and clamp:
+            raise TypeError("Clamping is not supported for open bounds.")
+
+    def _clamp(self, bound: float, dir: "te.Literal[1, -1]", open: bool) -> float:
+        if not open:
+            return bound
+
+        # Could use Python 3.9's math.nextafter here, but clamping an
+        # open float range doesn't seem to be particularly useful. It's
+        # left up to the user to write a callback to do it if needed.
+        raise RuntimeError("Clamping is not supported for open bounds.")
+
+
+class BoolParamType(ParamType):
+    name = "boolean"
+
+    def convert(
+        self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"]
+    ) -> t.Any:
+        if value in {False, True}:
+            return bool(value)
+
+        norm = value.strip().lower()
+
+        if norm in {"1", "true", "t", "yes", "y", "on"}:
+            return True
+
+        if norm in {"0", "false", "f", "no", "n", "off"}:
+            return False
+
+        self.fail(
+            _("{value!r} is not a valid boolean.").format(value=value), param, ctx
+        )
+
+    def __repr__(self) -> str:
+        return "BOOL"
+
+
+class UUIDParameterType(ParamType):
+    name = "uuid"
+
+    def convert(
+        self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"]
+    ) -> t.Any:
+        import uuid
+
+        if isinstance(value, uuid.UUID):
+            return value
+
+        value = value.strip()
+
+        try:
+            return uuid.UUID(value)
+        except ValueError:
+            self.fail(
+                _("{value!r} is not a valid UUID.").format(value=value), param, ctx
+            )
+
+    def __repr__(self) -> str:
+        return "UUID"
+
+
+class File(ParamType):
+    """Declares a parameter to be a file for reading or writing.  The file
+    is automatically closed once the context tears down (after the command
+    finished working).
+
+    Files can be opened for reading or writing.  The special value ``-``
+    indicates stdin or stdout depending on the mode.
+
+    By default, the file is opened for reading text data, but it can also be
+    opened in binary mode or for writing.  The encoding parameter can be used
+    to force a specific encoding.
+
+    The `lazy` flag controls if the file should be opened immediately or upon
+    first IO. The default is to be non-lazy for standard input and output
+    streams as well as files opened for reading, `lazy` otherwise. When opening a
+    file lazily for reading, it is still opened temporarily for validation, but
+    will not be held open until first IO. lazy is mainly useful when opening
+    for writing to avoid creating the file until it is needed.
+
+    Files can also be opened atomically in which case all writes go into a
+    separate file in the same folder and upon completion the file will
+    be moved over to the original location.  This is useful if a file
+    regularly read by other users is modified.
+
+    See :ref:`file-args` for more information.
+
+    .. versionchanged:: 2.0
+        Added the ``atomic`` parameter.
+    """
+
+    name = "filename"
+    envvar_list_splitter: t.ClassVar[str] = os.path.pathsep
+
+    def __init__(
+        self,
+        mode: str = "r",
+        encoding: t.Optional[str] = None,
+        errors: t.Optional[str] = "strict",
+        lazy: t.Optional[bool] = None,
+        atomic: bool = False,
+    ) -> None:
+        self.mode = mode
+        self.encoding = encoding
+        self.errors = errors
+        self.lazy = lazy
+        self.atomic = atomic
+
+    def to_info_dict(self) -> t.Dict[str, t.Any]:
+        info_dict = super().to_info_dict()
+        info_dict.update(mode=self.mode, encoding=self.encoding)
+        return info_dict
+
+    def resolve_lazy_flag(self, value: "t.Union[str, os.PathLike[str]]") -> bool:
+        if self.lazy is not None:
+            return self.lazy
+        if os.fspath(value) == "-":
+            return False
+        elif "w" in self.mode:
+            return True
+        return False
+
+    def convert(
+        self,
+        value: t.Union[str, "os.PathLike[str]", t.IO[t.Any]],
+        param: t.Optional["Parameter"],
+        ctx: t.Optional["Context"],
+    ) -> t.IO[t.Any]:
+        if _is_file_like(value):
+            return value
+
+        value = t.cast("t.Union[str, os.PathLike[str]]", value)
+
+        try:
+            lazy = self.resolve_lazy_flag(value)
+
+            if lazy:
+                lf = LazyFile(
+                    value, self.mode, self.encoding, self.errors, atomic=self.atomic
+                )
+
+                if ctx is not None:
+                    ctx.call_on_close(lf.close_intelligently)
+
+                return t.cast(t.IO[t.Any], lf)
+
+            f, should_close = open_stream(
+                value, self.mode, self.encoding, self.errors, atomic=self.atomic
+            )
+
+            # If a context is provided, we automatically close the file
+            # at the end of the context execution (or flush out).  If a
+            # context does not exist, it's the caller's responsibility to
+            # properly close the file.  This for instance happens when the
+            # type is used with prompts.
+            if ctx is not None:
+                if should_close:
+                    ctx.call_on_close(safecall(f.close))
+                else:
+                    ctx.call_on_close(safecall(f.flush))
+
+            return f
+        except OSError as e:
+            self.fail(f"'{format_filename(value)}': {e.strerror}", param, ctx)
+
+    def shell_complete(
+        self, ctx: "Context", param: "Parameter", incomplete: str
+    ) -> t.List["CompletionItem"]:
+        """Return a special completion marker that tells the completion
+        system to use the shell to provide file path completions.
+
+        :param ctx: Invocation context for this command.
+        :param param: The parameter that is requesting completion.
+        :param incomplete: Value being completed. May be empty.
+
+        .. versionadded:: 8.0
+        """
+        from click.shell_completion import CompletionItem
+
+        return [CompletionItem(incomplete, type="file")]
+
+
+def _is_file_like(value: t.Any) -> "te.TypeGuard[t.IO[t.Any]]":
+    return hasattr(value, "read") or hasattr(value, "write")
+
+
+class Path(ParamType):
+    """The ``Path`` type is similar to the :class:`File` type, but
+    returns the filename instead of an open file. Various checks can be
+    enabled to validate the type of file and permissions.
+
+    :param exists: The file or directory needs to exist for the value to
+        be valid. If this is not set to ``True``, and the file does not
+        exist, then all further checks are silently skipped.
+    :param file_okay: Allow a file as a value.
+    :param dir_okay: Allow a directory as a value.
+    :param readable: if true, a readable check is performed.
+    :param writable: if true, a writable check is performed.
+    :param executable: if true, an executable check is performed.
+    :param resolve_path: Make the value absolute and resolve any
+        symlinks. A ``~`` is not expanded, as this is supposed to be
+        done by the shell only.
+    :param allow_dash: Allow a single dash as a value, which indicates
+        a standard stream (but does not open it). Use
+        :func:`~click.open_file` to handle opening this value.
+    :param path_type: Convert the incoming path value to this type. If
+        ``None``, keep Python's default, which is ``str``. Useful to
+        convert to :class:`pathlib.Path`.
+
+    .. versionchanged:: 8.1
+        Added the ``executable`` parameter.
+
+    .. versionchanged:: 8.0
+        Allow passing ``path_type=pathlib.Path``.
+
+    .. versionchanged:: 6.0
+        Added the ``allow_dash`` parameter.
+    """
+
+    envvar_list_splitter: t.ClassVar[str] = os.path.pathsep
+
+    def __init__(
+        self,
+        exists: bool = False,
+        file_okay: bool = True,
+        dir_okay: bool = True,
+        writable: bool = False,
+        readable: bool = True,
+        resolve_path: bool = False,
+        allow_dash: bool = False,
+        path_type: t.Optional[t.Type[t.Any]] = None,
+        executable: bool = False,
+    ):
+        self.exists = exists
+        self.file_okay = file_okay
+        self.dir_okay = dir_okay
+        self.readable = readable
+        self.writable = writable
+        self.executable = executable
+        self.resolve_path = resolve_path
+        self.allow_dash = allow_dash
+        self.type = path_type
+
+        if self.file_okay and not self.dir_okay:
+            self.name: str = _("file")
+        elif self.dir_okay and not self.file_okay:
+            self.name = _("directory")
+        else:
+            self.name = _("path")
+
+    def to_info_dict(self) -> t.Dict[str, t.Any]:
+        info_dict = super().to_info_dict()
+        info_dict.update(
+            exists=self.exists,
+            file_okay=self.file_okay,
+            dir_okay=self.dir_okay,
+            writable=self.writable,
+            readable=self.readable,
+            allow_dash=self.allow_dash,
+        )
+        return info_dict
+
+    def coerce_path_result(
+        self, value: "t.Union[str, os.PathLike[str]]"
+    ) -> "t.Union[str, bytes, os.PathLike[str]]":
+        if self.type is not None and not isinstance(value, self.type):
+            if self.type is str:
+                return os.fsdecode(value)
+            elif self.type is bytes:
+                return os.fsencode(value)
+            else:
+                return t.cast("os.PathLike[str]", self.type(value))
+
+        return value
+
+    def convert(
+        self,
+        value: "t.Union[str, os.PathLike[str]]",
+        param: t.Optional["Parameter"],
+        ctx: t.Optional["Context"],
+    ) -> "t.Union[str, bytes, os.PathLike[str]]":
+        rv = value
+
+        is_dash = self.file_okay and self.allow_dash and rv in (b"-", "-")
+
+        if not is_dash:
+            if self.resolve_path:
+                # os.path.realpath doesn't resolve symlinks on Windows
+                # until Python 3.8. Use pathlib for now.
+                import pathlib
+
+                rv = os.fsdecode(pathlib.Path(rv).resolve())
+
+            try:
+                st = os.stat(rv)
+            except OSError:
+                if not self.exists:
+                    return self.coerce_path_result(rv)
+                self.fail(
+                    _("{name} {filename!r} does not exist.").format(
+                        name=self.name.title(), filename=format_filename(value)
+                    ),
+                    param,
+                    ctx,
+                )
+
+            if not self.file_okay and stat.S_ISREG(st.st_mode):
+                self.fail(
+                    _("{name} {filename!r} is a file.").format(
+                        name=self.name.title(), filename=format_filename(value)
+                    ),
+                    param,
+                    ctx,
+                )
+            if not self.dir_okay and stat.S_ISDIR(st.st_mode):
+                self.fail(
+                    _("{name} {filename!r} is a directory.").format(
+                        name=self.name.title(), filename=format_filename(value)
+                    ),
+                    param,
+                    ctx,
+                )
+
+            if self.readable and not os.access(rv, os.R_OK):
+                self.fail(
+                    _("{name} {filename!r} is not readable.").format(
+                        name=self.name.title(), filename=format_filename(value)
+                    ),
+                    param,
+                    ctx,
+                )
+
+            if self.writable and not os.access(rv, os.W_OK):
+                self.fail(
+                    _("{name} {filename!r} is not writable.").format(
+                        name=self.name.title(), filename=format_filename(value)
+                    ),
+                    param,
+                    ctx,
+                )
+
+            if self.executable and not os.access(value, os.X_OK):
+                self.fail(
+                    _("{name} {filename!r} is not executable.").format(
+                        name=self.name.title(), filename=format_filename(value)
+                    ),
+                    param,
+                    ctx,
+                )
+
+        return self.coerce_path_result(rv)
+
+    def shell_complete(
+        self, ctx: "Context", param: "Parameter", incomplete: str
+    ) -> t.List["CompletionItem"]:
+        """Return a special completion marker that tells the completion
+        system to use the shell to provide path completions for only
+        directories or any paths.
+
+        :param ctx: Invocation context for this command.
+        :param param: The parameter that is requesting completion.
+        :param incomplete: Value being completed. May be empty.
+
+        .. versionadded:: 8.0
+        """
+        from click.shell_completion import CompletionItem
+
+        type = "dir" if self.dir_okay and not self.file_okay else "file"
+        return [CompletionItem(incomplete, type=type)]
+
+
+class Tuple(CompositeParamType):
+    """The default behavior of Click is to apply a type on a value directly.
+    This works well in most cases, except for when `nargs` is set to a fixed
+    count and different types should be used for different items.  In this
+    case the :class:`Tuple` type can be used.  This type can only be used
+    if `nargs` is set to a fixed number.
+
+    For more information see :ref:`tuple-type`.
+
+    This can be selected by using a Python tuple literal as a type.
+
+    :param types: a list of types that should be used for the tuple items.
+    """
+
+    def __init__(self, types: t.Sequence[t.Union[t.Type[t.Any], ParamType]]) -> None:
+        self.types: t.Sequence[ParamType] = [convert_type(ty) for ty in types]
+
+    def to_info_dict(self) -> t.Dict[str, t.Any]:
+        info_dict = super().to_info_dict()
+        info_dict["types"] = [t.to_info_dict() for t in self.types]
+        return info_dict
+
+    @property
+    def name(self) -> str:  # type: ignore
+        return f"<{' '.join(ty.name for ty in self.types)}>"
+
+    @property
+    def arity(self) -> int:  # type: ignore
+        return len(self.types)
+
+    def convert(
+        self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"]
+    ) -> t.Any:
+        len_type = len(self.types)
+        len_value = len(value)
+
+        if len_value != len_type:
+            self.fail(
+                ngettext(
+                    "{len_type} values are required, but {len_value} was given.",
+                    "{len_type} values are required, but {len_value} were given.",
+                    len_value,
+                ).format(len_type=len_type, len_value=len_value),
+                param=param,
+                ctx=ctx,
+            )
+
+        return tuple(ty(x, param, ctx) for ty, x in zip(self.types, value))
+
+
+def convert_type(ty: t.Optional[t.Any], default: t.Optional[t.Any] = None) -> ParamType:
+    """Find the most appropriate :class:`ParamType` for the given Python
+    type. If the type isn't provided, it can be inferred from a default
+    value.
+    """
+    guessed_type = False
+
+    if ty is None and default is not None:
+        if isinstance(default, (tuple, list)):
+            # If the default is empty, ty will remain None and will
+            # return STRING.
+            if default:
+                item = default[0]
+
+                # A tuple of tuples needs to detect the inner types.
+                # Can't call convert recursively because that would
+                # incorrectly unwind the tuple to a single type.
+                if isinstance(item, (tuple, list)):
+                    ty = tuple(map(type, item))
+                else:
+                    ty = type(item)
+        else:
+            ty = type(default)
+
+        guessed_type = True
+
+    if isinstance(ty, tuple):
+        return Tuple(ty)
+
+    if isinstance(ty, ParamType):
+        return ty
+
+    if ty is str or ty is None:
+        return STRING
+
+    if ty is int:
+        return INT
+
+    if ty is float:
+        return FLOAT
+
+    if ty is bool:
+        return BOOL
+
+    if guessed_type:
+        return STRING
+
+    if __debug__:
+        try:
+            if issubclass(ty, ParamType):
+                raise AssertionError(
+                    f"Attempted to use an uninstantiated parameter type ({ty})."
+                )
+        except TypeError:
+            # ty is an instance (correct), so issubclass fails.
+            pass
+
+    return FuncParamType(ty)
+
+
+#: A dummy parameter type that just does nothing.  From a user's
+#: perspective this appears to just be the same as `STRING` but
+#: internally no string conversion takes place if the input was bytes.
+#: This is usually useful when working with file paths as they can
+#: appear in bytes and unicode.
+#:
+#: For path related uses the :class:`Path` type is a better choice but
+#: there are situations where an unprocessed type is useful which is why
+#: it is is provided.
+#:
+#: .. versionadded:: 4.0
+UNPROCESSED = UnprocessedParamType()
+
+#: A unicode string parameter type which is the implicit default.  This
+#: can also be selected by using ``str`` as type.
+STRING = StringParamType()
+
+#: An integer parameter.  This can also be selected by using ``int`` as
+#: type.
+INT = IntParamType()
+
+#: A floating point value parameter.  This can also be selected by using
+#: ``float`` as type.
+FLOAT = FloatParamType()
+
+#: A boolean parameter.  This is the default for boolean flags.  This can
+#: also be selected by using ``bool`` as a type.
+BOOL = BoolParamType()
+
+#: A UUID parameter.
+UUID = UUIDParameterType()

+ 624 - 0
venv/lib/python3.9/site-packages/click/utils.py

@@ -0,0 +1,624 @@
+import os
+import re
+import sys
+import typing as t
+from functools import update_wrapper
+from types import ModuleType
+from types import TracebackType
+
+from ._compat import _default_text_stderr
+from ._compat import _default_text_stdout
+from ._compat import _find_binary_writer
+from ._compat import auto_wrap_for_ansi
+from ._compat import binary_streams
+from ._compat import open_stream
+from ._compat import should_strip_ansi
+from ._compat import strip_ansi
+from ._compat import text_streams
+from ._compat import WIN
+from .globals import resolve_color_default
+
+if t.TYPE_CHECKING:
+    import typing_extensions as te
+
+    P = te.ParamSpec("P")
+
+R = t.TypeVar("R")
+
+
+def _posixify(name: str) -> str:
+    return "-".join(name.split()).lower()
+
+
+def safecall(func: "t.Callable[P, R]") -> "t.Callable[P, t.Optional[R]]":
+    """Wraps a function so that it swallows exceptions."""
+
+    def wrapper(*args: "P.args", **kwargs: "P.kwargs") -> t.Optional[R]:
+        try:
+            return func(*args, **kwargs)
+        except Exception:
+            pass
+        return None
+
+    return update_wrapper(wrapper, func)
+
+
+def make_str(value: t.Any) -> str:
+    """Converts a value into a valid string."""
+    if isinstance(value, bytes):
+        try:
+            return value.decode(sys.getfilesystemencoding())
+        except UnicodeError:
+            return value.decode("utf-8", "replace")
+    return str(value)
+
+
+def make_default_short_help(help: str, max_length: int = 45) -> str:
+    """Returns a condensed version of help string."""
+    # Consider only the first paragraph.
+    paragraph_end = help.find("\n\n")
+
+    if paragraph_end != -1:
+        help = help[:paragraph_end]
+
+    # Collapse newlines, tabs, and spaces.
+    words = help.split()
+
+    if not words:
+        return ""
+
+    # The first paragraph started with a "no rewrap" marker, ignore it.
+    if words[0] == "\b":
+        words = words[1:]
+
+    total_length = 0
+    last_index = len(words) - 1
+
+    for i, word in enumerate(words):
+        total_length += len(word) + (i > 0)
+
+        if total_length > max_length:  # too long, truncate
+            break
+
+        if word[-1] == ".":  # sentence end, truncate without "..."
+            return " ".join(words[: i + 1])
+
+        if total_length == max_length and i != last_index:
+            break  # not at sentence end, truncate with "..."
+    else:
+        return " ".join(words)  # no truncation needed
+
+    # Account for the length of the suffix.
+    total_length += len("...")
+
+    # remove words until the length is short enough
+    while i > 0:
+        total_length -= len(words[i]) + (i > 0)
+
+        if total_length <= max_length:
+            break
+
+        i -= 1
+
+    return " ".join(words[:i]) + "..."
+
+
+class LazyFile:
+    """A lazy file works like a regular file but it does not fully open
+    the file but it does perform some basic checks early to see if the
+    filename parameter does make sense.  This is useful for safely opening
+    files for writing.
+    """
+
+    def __init__(
+        self,
+        filename: t.Union[str, "os.PathLike[str]"],
+        mode: str = "r",
+        encoding: t.Optional[str] = None,
+        errors: t.Optional[str] = "strict",
+        atomic: bool = False,
+    ):
+        self.name: str = os.fspath(filename)
+        self.mode = mode
+        self.encoding = encoding
+        self.errors = errors
+        self.atomic = atomic
+        self._f: t.Optional[t.IO[t.Any]]
+        self.should_close: bool
+
+        if self.name == "-":
+            self._f, self.should_close = open_stream(filename, mode, encoding, errors)
+        else:
+            if "r" in mode:
+                # Open and close the file in case we're opening it for
+                # reading so that we can catch at least some errors in
+                # some cases early.
+                open(filename, mode).close()
+            self._f = None
+            self.should_close = True
+
+    def __getattr__(self, name: str) -> t.Any:
+        return getattr(self.open(), name)
+
+    def __repr__(self) -> str:
+        if self._f is not None:
+            return repr(self._f)
+        return f"<unopened file '{format_filename(self.name)}' {self.mode}>"
+
+    def open(self) -> t.IO[t.Any]:
+        """Opens the file if it's not yet open.  This call might fail with
+        a :exc:`FileError`.  Not handling this error will produce an error
+        that Click shows.
+        """
+        if self._f is not None:
+            return self._f
+        try:
+            rv, self.should_close = open_stream(
+                self.name, self.mode, self.encoding, self.errors, atomic=self.atomic
+            )
+        except OSError as e:
+            from .exceptions import FileError
+
+            raise FileError(self.name, hint=e.strerror) from e
+        self._f = rv
+        return rv
+
+    def close(self) -> None:
+        """Closes the underlying file, no matter what."""
+        if self._f is not None:
+            self._f.close()
+
+    def close_intelligently(self) -> None:
+        """This function only closes the file if it was opened by the lazy
+        file wrapper.  For instance this will never close stdin.
+        """
+        if self.should_close:
+            self.close()
+
+    def __enter__(self) -> "LazyFile":
+        return self
+
+    def __exit__(
+        self,
+        exc_type: t.Optional[t.Type[BaseException]],
+        exc_value: t.Optional[BaseException],
+        tb: t.Optional[TracebackType],
+    ) -> None:
+        self.close_intelligently()
+
+    def __iter__(self) -> t.Iterator[t.AnyStr]:
+        self.open()
+        return iter(self._f)  # type: ignore
+
+
+class KeepOpenFile:
+    def __init__(self, file: t.IO[t.Any]) -> None:
+        self._file: t.IO[t.Any] = file
+
+    def __getattr__(self, name: str) -> t.Any:
+        return getattr(self._file, name)
+
+    def __enter__(self) -> "KeepOpenFile":
+        return self
+
+    def __exit__(
+        self,
+        exc_type: t.Optional[t.Type[BaseException]],
+        exc_value: t.Optional[BaseException],
+        tb: t.Optional[TracebackType],
+    ) -> None:
+        pass
+
+    def __repr__(self) -> str:
+        return repr(self._file)
+
+    def __iter__(self) -> t.Iterator[t.AnyStr]:
+        return iter(self._file)
+
+
+def echo(
+    message: t.Optional[t.Any] = None,
+    file: t.Optional[t.IO[t.Any]] = None,
+    nl: bool = True,
+    err: bool = False,
+    color: t.Optional[bool] = None,
+) -> None:
+    """Print a message and newline to stdout or a file. This should be
+    used instead of :func:`print` because it provides better support
+    for different data, files, and environments.
+
+    Compared to :func:`print`, this does the following:
+
+    -   Ensures that the output encoding is not misconfigured on Linux.
+    -   Supports Unicode in the Windows console.
+    -   Supports writing to binary outputs, and supports writing bytes
+        to text outputs.
+    -   Supports colors and styles on Windows.
+    -   Removes ANSI color and style codes if the output does not look
+        like an interactive terminal.
+    -   Always flushes the output.
+
+    :param message: The string or bytes to output. Other objects are
+        converted to strings.
+    :param file: The file to write to. Defaults to ``stdout``.
+    :param err: Write to ``stderr`` instead of ``stdout``.
+    :param nl: Print a newline after the message. Enabled by default.
+    :param color: Force showing or hiding colors and other styles. By
+        default Click will remove color if the output does not look like
+        an interactive terminal.
+
+    .. versionchanged:: 6.0
+        Support Unicode output on the Windows console. Click does not
+        modify ``sys.stdout``, so ``sys.stdout.write()`` and ``print()``
+        will still not support Unicode.
+
+    .. versionchanged:: 4.0
+        Added the ``color`` parameter.
+
+    .. versionadded:: 3.0
+        Added the ``err`` parameter.
+
+    .. versionchanged:: 2.0
+        Support colors on Windows if colorama is installed.
+    """
+    if file is None:
+        if err:
+            file = _default_text_stderr()
+        else:
+            file = _default_text_stdout()
+
+        # There are no standard streams attached to write to. For example,
+        # pythonw on Windows.
+        if file is None:
+            return
+
+    # Convert non bytes/text into the native string type.
+    if message is not None and not isinstance(message, (str, bytes, bytearray)):
+        out: t.Optional[t.Union[str, bytes]] = str(message)
+    else:
+        out = message
+
+    if nl:
+        out = out or ""
+        if isinstance(out, str):
+            out += "\n"
+        else:
+            out += b"\n"
+
+    if not out:
+        file.flush()
+        return
+
+    # If there is a message and the value looks like bytes, we manually
+    # need to find the binary stream and write the message in there.
+    # This is done separately so that most stream types will work as you
+    # would expect. Eg: you can write to StringIO for other cases.
+    if isinstance(out, (bytes, bytearray)):
+        binary_file = _find_binary_writer(file)
+
+        if binary_file is not None:
+            file.flush()
+            binary_file.write(out)
+            binary_file.flush()
+            return
+
+    # ANSI style code support. For no message or bytes, nothing happens.
+    # When outputting to a file instead of a terminal, strip codes.
+    else:
+        color = resolve_color_default(color)
+
+        if should_strip_ansi(file, color):
+            out = strip_ansi(out)
+        elif WIN:
+            if auto_wrap_for_ansi is not None:
+                file = auto_wrap_for_ansi(file, color)  # type: ignore
+            elif not color:
+                out = strip_ansi(out)
+
+    file.write(out)  # type: ignore
+    file.flush()
+
+
+def get_binary_stream(name: "te.Literal['stdin', 'stdout', 'stderr']") -> t.BinaryIO:
+    """Returns a system stream for byte processing.
+
+    :param name: the name of the stream to open.  Valid names are ``'stdin'``,
+                 ``'stdout'`` and ``'stderr'``
+    """
+    opener = binary_streams.get(name)
+    if opener is None:
+        raise TypeError(f"Unknown standard stream '{name}'")
+    return opener()
+
+
+def get_text_stream(
+    name: "te.Literal['stdin', 'stdout', 'stderr']",
+    encoding: t.Optional[str] = None,
+    errors: t.Optional[str] = "strict",
+) -> t.TextIO:
+    """Returns a system stream for text processing.  This usually returns
+    a wrapped stream around a binary stream returned from
+    :func:`get_binary_stream` but it also can take shortcuts for already
+    correctly configured streams.
+
+    :param name: the name of the stream to open.  Valid names are ``'stdin'``,
+                 ``'stdout'`` and ``'stderr'``
+    :param encoding: overrides the detected default encoding.
+    :param errors: overrides the default error mode.
+    """
+    opener = text_streams.get(name)
+    if opener is None:
+        raise TypeError(f"Unknown standard stream '{name}'")
+    return opener(encoding, errors)
+
+
+def open_file(
+    filename: t.Union[str, "os.PathLike[str]"],
+    mode: str = "r",
+    encoding: t.Optional[str] = None,
+    errors: t.Optional[str] = "strict",
+    lazy: bool = False,
+    atomic: bool = False,
+) -> t.IO[t.Any]:
+    """Open a file, with extra behavior to handle ``'-'`` to indicate
+    a standard stream, lazy open on write, and atomic write. Similar to
+    the behavior of the :class:`~click.File` param type.
+
+    If ``'-'`` is given to open ``stdout`` or ``stdin``, the stream is
+    wrapped so that using it in a context manager will not close it.
+    This makes it possible to use the function without accidentally
+    closing a standard stream:
+
+    .. code-block:: python
+
+        with open_file(filename) as f:
+            ...
+
+    :param filename: The name or Path of the file to open, or ``'-'`` for
+        ``stdin``/``stdout``.
+    :param mode: The mode in which to open the file.
+    :param encoding: The encoding to decode or encode a file opened in
+        text mode.
+    :param errors: The error handling mode.
+    :param lazy: Wait to open the file until it is accessed. For read
+        mode, the file is temporarily opened to raise access errors
+        early, then closed until it is read again.
+    :param atomic: Write to a temporary file and replace the given file
+        on close.
+
+    .. versionadded:: 3.0
+    """
+    if lazy:
+        return t.cast(
+            t.IO[t.Any], LazyFile(filename, mode, encoding, errors, atomic=atomic)
+        )
+
+    f, should_close = open_stream(filename, mode, encoding, errors, atomic=atomic)
+
+    if not should_close:
+        f = t.cast(t.IO[t.Any], KeepOpenFile(f))
+
+    return f
+
+
+def format_filename(
+    filename: "t.Union[str, bytes, os.PathLike[str], os.PathLike[bytes]]",
+    shorten: bool = False,
+) -> str:
+    """Format a filename as a string for display. Ensures the filename can be
+    displayed by replacing any invalid bytes or surrogate escapes in the name
+    with the replacement character ``�``.
+
+    Invalid bytes or surrogate escapes will raise an error when written to a
+    stream with ``errors="strict"``. This will typically happen with ``stdout``
+    when the locale is something like ``en_GB.UTF-8``.
+
+    Many scenarios *are* safe to write surrogates though, due to PEP 538 and
+    PEP 540, including:
+
+    -   Writing to ``stderr``, which uses ``errors="backslashreplace"``.
+    -   The system has ``LANG=C.UTF-8``, ``C``, or ``POSIX``. Python opens
+        stdout and stderr with ``errors="surrogateescape"``.
+    -   None of ``LANG/LC_*`` are set. Python assumes ``LANG=C.UTF-8``.
+    -   Python is started in UTF-8 mode  with  ``PYTHONUTF8=1`` or ``-X utf8``.
+        Python opens stdout and stderr with ``errors="surrogateescape"``.
+
+    :param filename: formats a filename for UI display.  This will also convert
+                     the filename into unicode without failing.
+    :param shorten: this optionally shortens the filename to strip of the
+                    path that leads up to it.
+    """
+    if shorten:
+        filename = os.path.basename(filename)
+    else:
+        filename = os.fspath(filename)
+
+    if isinstance(filename, bytes):
+        filename = filename.decode(sys.getfilesystemencoding(), "replace")
+    else:
+        filename = filename.encode("utf-8", "surrogateescape").decode(
+            "utf-8", "replace"
+        )
+
+    return filename
+
+
+def get_app_dir(app_name: str, roaming: bool = True, force_posix: bool = False) -> str:
+    r"""Returns the config folder for the application.  The default behavior
+    is to return whatever is most appropriate for the operating system.
+
+    To give you an idea, for an app called ``"Foo Bar"``, something like
+    the following folders could be returned:
+
+    Mac OS X:
+      ``~/Library/Application Support/Foo Bar``
+    Mac OS X (POSIX):
+      ``~/.foo-bar``
+    Unix:
+      ``~/.config/foo-bar``
+    Unix (POSIX):
+      ``~/.foo-bar``
+    Windows (roaming):
+      ``C:\Users\<user>\AppData\Roaming\Foo Bar``
+    Windows (not roaming):
+      ``C:\Users\<user>\AppData\Local\Foo Bar``
+
+    .. versionadded:: 2.0
+
+    :param app_name: the application name.  This should be properly capitalized
+                     and can contain whitespace.
+    :param roaming: controls if the folder should be roaming or not on Windows.
+                    Has no effect otherwise.
+    :param force_posix: if this is set to `True` then on any POSIX system the
+                        folder will be stored in the home folder with a leading
+                        dot instead of the XDG config home or darwin's
+                        application support folder.
+    """
+    if WIN:
+        key = "APPDATA" if roaming else "LOCALAPPDATA"
+        folder = os.environ.get(key)
+        if folder is None:
+            folder = os.path.expanduser("~")
+        return os.path.join(folder, app_name)
+    if force_posix:
+        return os.path.join(os.path.expanduser(f"~/.{_posixify(app_name)}"))
+    if sys.platform == "darwin":
+        return os.path.join(
+            os.path.expanduser("~/Library/Application Support"), app_name
+        )
+    return os.path.join(
+        os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")),
+        _posixify(app_name),
+    )
+
+
+class PacifyFlushWrapper:
+    """This wrapper is used to catch and suppress BrokenPipeErrors resulting
+    from ``.flush()`` being called on broken pipe during the shutdown/final-GC
+    of the Python interpreter. Notably ``.flush()`` is always called on
+    ``sys.stdout`` and ``sys.stderr``. So as to have minimal impact on any
+    other cleanup code, and the case where the underlying file is not a broken
+    pipe, all calls and attributes are proxied.
+    """
+
+    def __init__(self, wrapped: t.IO[t.Any]) -> None:
+        self.wrapped = wrapped
+
+    def flush(self) -> None:
+        try:
+            self.wrapped.flush()
+        except OSError as e:
+            import errno
+
+            if e.errno != errno.EPIPE:
+                raise
+
+    def __getattr__(self, attr: str) -> t.Any:
+        return getattr(self.wrapped, attr)
+
+
+def _detect_program_name(
+    path: t.Optional[str] = None, _main: t.Optional[ModuleType] = None
+) -> str:
+    """Determine the command used to run the program, for use in help
+    text. If a file or entry point was executed, the file name is
+    returned. If ``python -m`` was used to execute a module or package,
+    ``python -m name`` is returned.
+
+    This doesn't try to be too precise, the goal is to give a concise
+    name for help text. Files are only shown as their name without the
+    path. ``python`` is only shown for modules, and the full path to
+    ``sys.executable`` is not shown.
+
+    :param path: The Python file being executed. Python puts this in
+        ``sys.argv[0]``, which is used by default.
+    :param _main: The ``__main__`` module. This should only be passed
+        during internal testing.
+
+    .. versionadded:: 8.0
+        Based on command args detection in the Werkzeug reloader.
+
+    :meta private:
+    """
+    if _main is None:
+        _main = sys.modules["__main__"]
+
+    if not path:
+        path = sys.argv[0]
+
+    # The value of __package__ indicates how Python was called. It may
+    # not exist if a setuptools script is installed as an egg. It may be
+    # set incorrectly for entry points created with pip on Windows.
+    # It is set to "" inside a Shiv or PEX zipapp.
+    if getattr(_main, "__package__", None) in {None, ""} or (
+        os.name == "nt"
+        and _main.__package__ == ""
+        and not os.path.exists(path)
+        and os.path.exists(f"{path}.exe")
+    ):
+        # Executed a file, like "python app.py".
+        return os.path.basename(path)
+
+    # Executed a module, like "python -m example".
+    # Rewritten by Python from "-m script" to "/path/to/script.py".
+    # Need to look at main module to determine how it was executed.
+    py_module = t.cast(str, _main.__package__)
+    name = os.path.splitext(os.path.basename(path))[0]
+
+    # A submodule like "example.cli".
+    if name != "__main__":
+        py_module = f"{py_module}.{name}"
+
+    return f"python -m {py_module.lstrip('.')}"
+
+
+def _expand_args(
+    args: t.Iterable[str],
+    *,
+    user: bool = True,
+    env: bool = True,
+    glob_recursive: bool = True,
+) -> t.List[str]:
+    """Simulate Unix shell expansion with Python functions.
+
+    See :func:`glob.glob`, :func:`os.path.expanduser`, and
+    :func:`os.path.expandvars`.
+
+    This is intended for use on Windows, where the shell does not do any
+    expansion. It may not exactly match what a Unix shell would do.
+
+    :param args: List of command line arguments to expand.
+    :param user: Expand user home directory.
+    :param env: Expand environment variables.
+    :param glob_recursive: ``**`` matches directories recursively.
+
+    .. versionchanged:: 8.1
+        Invalid glob patterns are treated as empty expansions rather
+        than raising an error.
+
+    .. versionadded:: 8.0
+
+    :meta private:
+    """
+    from glob import glob
+
+    out = []
+
+    for arg in args:
+        if user:
+            arg = os.path.expanduser(arg)
+
+        if env:
+            arg = os.path.expandvars(arg)
+
+        try:
+            matches = glob(arg, recursive=glob_recursive)
+        except re.error:
+            matches = []
+
+        if not matches:
+            out.append(arg)
+        else:
+            out.extend(matches)
+
+    return out

+ 5 - 0
venv/lib/python3.9/site-packages/easy_install.py

@@ -0,0 +1,5 @@
+"""Run the EasyInstall command"""
+
+if __name__ == '__main__':
+    from setuptools.command.easy_install import main
+    main()

+ 1 - 0
venv/lib/python3.9/site-packages/flask-3.0.3.dist-info/INSTALLER

@@ -0,0 +1 @@
+pip

+ 28 - 0
venv/lib/python3.9/site-packages/flask-3.0.3.dist-info/LICENSE.txt

@@ -0,0 +1,28 @@
+Copyright 2010 Pallets
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+1.  Redistributions of source code must retain the above copyright
+    notice, this list of conditions and the following disclaimer.
+
+2.  Redistributions in binary form must reproduce the above copyright
+    notice, this list of conditions and the following disclaimer in the
+    documentation and/or other materials provided with the distribution.
+
+3.  Neither the name of the copyright holder nor the names of its
+    contributors may be used to endorse or promote products derived from
+    this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 101 - 0
venv/lib/python3.9/site-packages/flask-3.0.3.dist-info/METADATA

@@ -0,0 +1,101 @@
+Metadata-Version: 2.1
+Name: Flask
+Version: 3.0.3
+Summary: A simple framework for building complex web applications.
+Maintainer-email: Pallets <contact@palletsprojects.com>
+Requires-Python: >=3.8
+Description-Content-Type: text/markdown
+Classifier: Development Status :: 5 - Production/Stable
+Classifier: Environment :: Web Environment
+Classifier: Framework :: Flask
+Classifier: Intended Audience :: Developers
+Classifier: License :: OSI Approved :: BSD License
+Classifier: Operating System :: OS Independent
+Classifier: Programming Language :: Python
+Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
+Classifier: Topic :: Internet :: WWW/HTTP :: WSGI
+Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
+Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
+Classifier: Typing :: Typed
+Requires-Dist: Werkzeug>=3.0.0
+Requires-Dist: Jinja2>=3.1.2
+Requires-Dist: itsdangerous>=2.1.2
+Requires-Dist: click>=8.1.3
+Requires-Dist: blinker>=1.6.2
+Requires-Dist: importlib-metadata>=3.6.0; python_version < '3.10'
+Requires-Dist: asgiref>=3.2 ; extra == "async"
+Requires-Dist: python-dotenv ; extra == "dotenv"
+Project-URL: Changes, https://flask.palletsprojects.com/changes/
+Project-URL: Chat, https://discord.gg/pallets
+Project-URL: Documentation, https://flask.palletsprojects.com/
+Project-URL: Donate, https://palletsprojects.com/donate
+Project-URL: Source, https://github.com/pallets/flask/
+Provides-Extra: async
+Provides-Extra: dotenv
+
+# Flask
+
+Flask is a lightweight [WSGI][] web application framework. It is designed
+to make getting started quick and easy, with the ability to scale up to
+complex applications. It began as a simple wrapper around [Werkzeug][]
+and [Jinja][], and has become one of the most popular Python web
+application frameworks.
+
+Flask offers suggestions, but doesn't enforce any dependencies or
+project layout. It is up to the developer to choose the tools and
+libraries they want to use. There are many extensions provided by the
+community that make adding new functionality easy.
+
+[WSGI]: https://wsgi.readthedocs.io/
+[Werkzeug]: https://werkzeug.palletsprojects.com/
+[Jinja]: https://jinja.palletsprojects.com/
+
+
+## Installing
+
+Install and update from [PyPI][] using an installer such as [pip][]:
+
+```
+$ pip install -U Flask
+```
+
+[PyPI]: https://pypi.org/project/Flask/
+[pip]: https://pip.pypa.io/en/stable/getting-started/
+
+
+## A Simple Example
+
+```python
+# save this as app.py
+from flask import Flask
+
+app = Flask(__name__)
+
+@app.route("/")
+def hello():
+    return "Hello, World!"
+```
+
+```
+$ flask run
+  * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
+```
+
+
+## Contributing
+
+For guidance on setting up a development environment and how to make a
+contribution to Flask, see the [contributing guidelines][].
+
+[contributing guidelines]: https://github.com/pallets/flask/blob/main/CONTRIBUTING.rst
+
+
+## Donate
+
+The Pallets organization develops and supports Flask and the libraries
+it uses. In order to grow the community of contributors and users, and
+allow the maintainers to devote more time to the projects, [please
+donate today][].
+
+[please donate today]: https://palletsprojects.com/donate
+

+ 58 - 0
venv/lib/python3.9/site-packages/flask-3.0.3.dist-info/RECORD

@@ -0,0 +1,58 @@
+../../../bin/flask,sha256=jb6QyVKKVpjrPiwyFCgLL1kCQuhHbBX5I8pNVlzdf-4,173
+flask-3.0.3.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
+flask-3.0.3.dist-info/LICENSE.txt,sha256=SJqOEQhQntmKN7uYPhHg9-HTHwvY-Zp5yESOf_N9B-o,1475
+flask-3.0.3.dist-info/METADATA,sha256=exPahy4aahjV-mYqd9qb5HNP8haB_IxTuaotoSvCtag,3177
+flask-3.0.3.dist-info/RECORD,,
+flask-3.0.3.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+flask-3.0.3.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
+flask-3.0.3.dist-info/entry_points.txt,sha256=bBP7hTOS5fz9zLtC7sPofBZAlMkEvBxu7KqS6l5lvc4,40
+flask/__init__.py,sha256=6xMqdVA0FIQ2U1KVaGX3lzNCdXPzoHPaa0hvQCNcfSk,2625
+flask/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
+flask/__pycache__/__init__.cpython-39.pyc,,
+flask/__pycache__/__main__.cpython-39.pyc,,
+flask/__pycache__/app.cpython-39.pyc,,
+flask/__pycache__/blueprints.cpython-39.pyc,,
+flask/__pycache__/cli.cpython-39.pyc,,
+flask/__pycache__/config.cpython-39.pyc,,
+flask/__pycache__/ctx.cpython-39.pyc,,
+flask/__pycache__/debughelpers.cpython-39.pyc,,
+flask/__pycache__/globals.cpython-39.pyc,,
+flask/__pycache__/helpers.cpython-39.pyc,,
+flask/__pycache__/logging.cpython-39.pyc,,
+flask/__pycache__/sessions.cpython-39.pyc,,
+flask/__pycache__/signals.cpython-39.pyc,,
+flask/__pycache__/templating.cpython-39.pyc,,
+flask/__pycache__/testing.cpython-39.pyc,,
+flask/__pycache__/typing.cpython-39.pyc,,
+flask/__pycache__/views.cpython-39.pyc,,
+flask/__pycache__/wrappers.cpython-39.pyc,,
+flask/app.py,sha256=7-lh6cIj27riTE1Q18Ok1p5nOZ8qYiMux4Btc6o6mNc,60143
+flask/blueprints.py,sha256=7INXPwTkUxfOQXOOv1yu52NpHPmPGI5fMTMFZ-BG9yY,4430
+flask/cli.py,sha256=OOaf_Efqih1i2in58j-5ZZZmQnPpaSfiUFbEjlL9bzw,35825
+flask/config.py,sha256=bLzLVAj-cq-Xotu9erqOFte0xSFaVXyfz0AkP4GbwmY,13312
+flask/ctx.py,sha256=4atDhJJ_cpV1VMq4qsfU4E_61M1oN93jlS2H9gjrl58,15120
+flask/debughelpers.py,sha256=PGIDhStW_efRjpaa3zHIpo-htStJOR41Ip3OJWPYBwo,6080
+flask/globals.py,sha256=XdQZmStBmPIs8t93tjx6pO7Bm3gobAaONWkFcUHaGas,1713
+flask/helpers.py,sha256=tYrcQ_73GuSZVEgwFr-eMmV69UriFQDBmt8wZJIAqvg,23084
+flask/json/__init__.py,sha256=hLNR898paqoefdeAhraa5wyJy-bmRB2k2dV4EgVy2Z8,5602
+flask/json/__pycache__/__init__.cpython-39.pyc,,
+flask/json/__pycache__/provider.cpython-39.pyc,,
+flask/json/__pycache__/tag.cpython-39.pyc,,
+flask/json/provider.py,sha256=q6iB83lSiopy80DZPrU-9mGcWwrD0mvLjiv9fHrRZgc,7646
+flask/json/tag.py,sha256=DhaNwuIOhdt2R74oOC9Y4Z8ZprxFYiRb5dUP5byyINw,9281
+flask/logging.py,sha256=8sM3WMTubi1cBb2c_lPkWpN0J8dMAqrgKRYLLi1dCVI,2377
+flask/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+flask/sansio/README.md,sha256=-0X1tECnilmz1cogx-YhNw5d7guK7GKrq_DEV2OzlU0,228
+flask/sansio/__pycache__/app.cpython-39.pyc,,
+flask/sansio/__pycache__/blueprints.cpython-39.pyc,,
+flask/sansio/__pycache__/scaffold.cpython-39.pyc,,
+flask/sansio/app.py,sha256=YG5Gf7JVf1c0yccWDZ86q5VSfJUidOVp27HFxFNxC7U,38053
+flask/sansio/blueprints.py,sha256=Tqe-7EkZ-tbWchm8iDoCfD848f0_3nLv6NNjeIPvHwM,24637
+flask/sansio/scaffold.py,sha256=WLV9TRQMMhGlXz-1OKtQ3lv6mtIBQZxdW2HezYrGxoI,30633
+flask/sessions.py,sha256=RU4lzm9MQW9CtH8rVLRTDm8USMJyT4LbvYe7sxM2__k,14807
+flask/signals.py,sha256=V7lMUww7CqgJ2ThUBn1PiatZtQanOyt7OZpu2GZI-34,750
+flask/templating.py,sha256=2TcXLT85Asflm2W9WOSFxKCmYn5e49w_Jkg9-NaaJWo,7537
+flask/testing.py,sha256=3BFXb3bP7R5r-XLBuobhczbxDu8-1LWRzYuhbr-lwaE,10163
+flask/typing.py,sha256=ZavK-wV28Yv8CQB7u73qZp_jLalpbWdrXS37QR1ftN0,3190
+flask/views.py,sha256=B66bTvYBBcHMYk4dA1ScZD0oTRTBl0I5smp1lRm9riI,6939
+flask/wrappers.py,sha256=m1j5tIJxIu8_sPPgTAB_G4TTh52Q-HoDuw_qHV5J59g,5831

+ 0 - 0
venv/lib/python3.9/site-packages/flask-3.0.3.dist-info/REQUESTED


+ 4 - 0
venv/lib/python3.9/site-packages/flask-3.0.3.dist-info/WHEEL

@@ -0,0 +1,4 @@
+Wheel-Version: 1.0
+Generator: flit 3.9.0
+Root-Is-Purelib: true
+Tag: py3-none-any

+ 3 - 0
venv/lib/python3.9/site-packages/flask-3.0.3.dist-info/entry_points.txt

@@ -0,0 +1,3 @@
+[console_scripts]
+flask=flask.cli:main
+

+ 60 - 0
venv/lib/python3.9/site-packages/flask/__init__.py

@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+import typing as t
+
+from . import json as json
+from .app import Flask as Flask
+from .blueprints import Blueprint as Blueprint
+from .config import Config as Config
+from .ctx import after_this_request as after_this_request
+from .ctx import copy_current_request_context as copy_current_request_context
+from .ctx import has_app_context as has_app_context
+from .ctx import has_request_context as has_request_context
+from .globals import current_app as current_app
+from .globals import g as g
+from .globals import request as request
+from .globals import session as session
+from .helpers import abort as abort
+from .helpers import flash as flash
+from .helpers import get_flashed_messages as get_flashed_messages
+from .helpers import get_template_attribute as get_template_attribute
+from .helpers import make_response as make_response
+from .helpers import redirect as redirect
+from .helpers import send_file as send_file
+from .helpers import send_from_directory as send_from_directory
+from .helpers import stream_with_context as stream_with_context
+from .helpers import url_for as url_for
+from .json import jsonify as jsonify
+from .signals import appcontext_popped as appcontext_popped
+from .signals import appcontext_pushed as appcontext_pushed
+from .signals import appcontext_tearing_down as appcontext_tearing_down
+from .signals import before_render_template as before_render_template
+from .signals import got_request_exception as got_request_exception
+from .signals import message_flashed as message_flashed
+from .signals import request_finished as request_finished
+from .signals import request_started as request_started
+from .signals import request_tearing_down as request_tearing_down
+from .signals import template_rendered as template_rendered
+from .templating import render_template as render_template
+from .templating import render_template_string as render_template_string
+from .templating import stream_template as stream_template
+from .templating import stream_template_string as stream_template_string
+from .wrappers import Request as Request
+from .wrappers import Response as Response
+
+
+def __getattr__(name: str) -> t.Any:
+    if name == "__version__":
+        import importlib.metadata
+        import warnings
+
+        warnings.warn(
+            "The '__version__' attribute is deprecated and will be removed in"
+            " Flask 3.1. Use feature detection or"
+            " 'importlib.metadata.version(\"flask\")' instead.",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        return importlib.metadata.version("flask")
+
+    raise AttributeError(name)

部分文件因为文件数量过多而无法显示