#!/usr/bin/env python3 """Namecheap API CLI wrapper for DNS management. Uses only the Python standard library (no third-party dependencies). The username can be cached in ``~/.namecheap-api`` while the API key is read from the ``NAMECHEAP_API_KEY`` environment variable (or prompted during setup) and is never passed on the command line, so it cannot leak via ``ps``/shell history. """ import argparse import getpass import json import os import re import stat import sys import urllib.error import urllib.parse import urllib.request import xml.etree.ElementTree as ET API_URL = "https://api.namecheap.com/xml.response" CONFIG_FILE = os.path.join(os.path.expanduser("~"), ".namecheap-api") # Known multi-part (second-level) public suffixes. Best-effort list, not a full # public-suffix database. For unlisted suffixes the domain is split on the last # dot, which is correct for single-label TLDs (.com, .io, .dev, ...). MULTI_PART_SUFFIXES = { "co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk", "ltd.uk", "plc.uk", "com.au", "net.au", "org.au", "id.au", "co.nz", "net.nz", "org.nz", "co.za", "org.za", "co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp", "co.kr", "or.kr", "ne.kr", "com.br", "net.br", "org.br", "com.cn", "net.cn", "org.cn", "co.in", "net.in", "org.in", "com.mx", "org.mx", "com.sg", "edu.sg", "com.tr", "co.il", "org.il", } # Cached public IP for the lifetime of the process. _public_ip = None # --- Output helpers ------------------------------------------------------- _USE_COLOR = sys.stdout.isatty() def _c(code, text): return f"\033[{code}m{text}\033[0m" if _USE_COLOR else text def err(msg): print(_c("0;31", "Error:") + " " + msg, file=sys.stderr) def success(msg): print(_c("0;32", "\u2713") + " " + msg) def info(msg): print(_c("0;36", "\u2139") + " " + msg) def warn(msg): print(_c("1;33", "\u26a0") + " " + msg) class NamecheapError(Exception): """Raised when the API returns a Status="ERROR" response.""" # --- Configuration -------------------------------------------------------- def _redact_sensitive_text(text): text = re.sub(r'(?i)(ApiKey=)[^&\s]+', r"\1[REDACTED]", text) text = re.sub(r'(?i)(NAMECHEAP_API_KEY\s*=\s*"?)[^"\n]+("?)', r"\1[REDACTED]\2", text) return text def _read_config_values(): """Return shell-style KEY="value" pairs from the local config file.""" if not os.path.isfile(CONFIG_FILE): return {} with open(CONFIG_FILE, "r", encoding="utf-8") as fh: content = fh.read() pattern = re.compile(r'^\s*([A-Z_]+)\s*=\s*"?([^"\n]*)"?\s*$', re.MULTILINE) return {m.group(1): m.group(2) for m in pattern.finditer(content)} def load_config(): """Return (api_user, api_key), preferring env vars for sensitive values.""" api_user = os.environ.get("NAMECHEAP_API_USER") api_key = os.environ.get("NAMECHEAP_API_KEY") values = _read_config_values() api_user = api_user or values.get("NAMECHEAP_API_USER") return api_user, api_key def has_legacy_api_key(): """Return True when a legacy clear-text API key is still present on disk.""" return bool(_read_config_values().get("NAMECHEAP_API_KEY")) def save_config(api_user): with open(CONFIG_FILE, "w", encoding="utf-8") as fh: fh.write("# Cached Namecheap username for this skill.\n") fh.write("# Export NAMECHEAP_API_KEY in your shell before running API commands.\n") fh.write(f'NAMECHEAP_API_USER="{api_user}"\n') os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 600 def print_safe_api_failure(prefix, public_ip=None): err(prefix) print("Please verify:") print(" 1. API access is enabled (ON) at the Namecheap settings page") if public_ip: print(f" 2. IP address {public_ip} is whitelisted") else: print(" 2. Your current public IP is whitelisted") print(" 3. Your username and API key are correct") def print_safe_namecheap_error(exc): message = _redact_sensitive_text(str(exc)).strip() if message: err(f"API returned error: {message}") else: err("API returned an error.") def print_safe_runtime_error(exc): if isinstance(exc, urllib.error.URLError): err("Network error while contacting Namecheap or resolving your public IP.") return if isinstance(exc, OSError): err(f"Could not read or update {CONFIG_FILE}.") return if isinstance(exc, ET.ParseError): err("Received an invalid XML response from the Namecheap API.") return if isinstance(exc, json.JSONDecodeError): err("Could not parse the JSON input file that was provided.") return err("The operation failed.") def print_credential_help(legacy_key_present=False): err("Namecheap API credentials are not configured.") print() print("Run 'python3 namecheap.py setup' to save your Namecheap username.") print("Then export NAMECHEAP_API_KEY in your own shell before running API commands.") print() print("You need:") print(" 1. Your Namecheap username") print(" 2. An API key from: https://ap.www.namecheap.com/settings/tools/apiaccess/") print(" 3. Your public IP whitelisted in the API settings") if legacy_key_present: print() warn(f"A legacy API key entry in {CONFIG_FILE} is ignored for security reasons.") print("Remove the NAMECHEAP_API_KEY line from that file after exporting the key securely.") def check_credentials(): api_user, api_key = load_config() legacy_key_present = has_legacy_api_key() if not api_user or not api_key: print_credential_help(legacy_key_present) sys.exit(1) return api_user, api_key # --- Networking ----------------------------------------------------------- def _http_get(url, timeout=15): req = urllib.request.Request(url, headers={"User-Agent": "namecheap-skill/1.0"}) with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (https only) return resp.read().decode("utf-8", errors="replace") def get_public_ip(): """Resolve the public IP once and cache it for subsequent calls.""" global _public_ip if _public_ip: return _public_ip for url in ("https://api.ipify.org", "https://ifconfig.me"): try: ip = _http_get(url, timeout=10).strip() if ip: _public_ip = ip return ip except Exception: continue _public_ip = "unknown" return _public_ip def _strip_namespaces(root): for elem in root.iter(): if isinstance(elem.tag, str) and "}" in elem.tag: elem.tag = elem.tag.split("}", 1)[1] return root def _check_error(root): if (root.attrib.get("Status") or "").upper() == "ERROR": messages = [] for e in root.iter("Err"): code = e.attrib.get("Number") or e.attrib.get("Code") or "" text = (e.text or "").strip() messages.append(f"[{code}] {text}" if code else text) raise NamecheapError("; ".join(m for m in messages if m) or "Unknown API error") def api_request(command, params=None): """Issue a Namecheap API GET request and return the parsed (ns-stripped) root. The API key is encoded into the request URL inside this process; it is never passed as a command-line argument, so it cannot leak via ``ps`` or shell history. Values are URL-encoded by ``urllib``. """ api_user, api_key = check_credentials() query = { "ApiUser": api_user, "ApiKey": api_key, "UserName": api_user, "Command": f"namecheap.{command}", "ClientIp": get_public_ip(), } for key, value in (params or {}).items(): if value is not None and value != "": query[key] = value url = f"{API_URL}?{urllib.parse.urlencode(query)}" body = _http_get(url, timeout=30) root = _strip_namespaces(ET.fromstring(body)) _check_error(root) return root def _attr(root, tag, name, default=""): for elem in root.iter(tag): return elem.attrib.get(name, default) return default # --- Domain parsing ------------------------------------------------------- def parse_domain(domain): """Split a registered domain into (SLD, TLD), handling multi-part TLDs.""" domain = domain.strip().rstrip(".").lower() labels = domain.split(".") if len(labels) >= 3 and ".".join(labels[-2:]) in MULTI_PART_SUFFIXES: tld = ".".join(labels[-2:]) sld = ".".join(labels[:-2]) elif len(labels) >= 2: tld = labels[-1] sld = ".".join(labels[:-1]) else: tld = "" sld = domain return sld, tld # --- Commands ------------------------------------------------------------- def cmd_public_ip(_args): print(get_public_ip()) def cmd_setup(_args): print("=== Namecheap API Setup ===\n") public_ip = get_public_ip() info("Your public IP address is: " + _c("0;36", public_ip)) print() print("Make sure this IP is whitelisted at:") print(" https://ap.www.namecheap.com/settings/tools/apiaccess/") print() existing_user, existing_key = load_config() if has_legacy_api_key(): warn(f"A legacy API key was found in {CONFIG_FILE} and will no longer be used.") print("Run setup to rewrite the file without the API key, then export NAMECHEAP_API_KEY securely.") print() if existing_user: info(f"Existing configuration found for user: {existing_user}") if existing_key: print("\nTesting API connection...") try: api_request("domains.getList", {"PageSize": "1"}) success("API connection successful!") except NamecheapError: print_safe_api_failure("API connection failed.", public_ip) except (urllib.error.URLError, OSError, ET.ParseError, json.JSONDecodeError) as exc: print_safe_runtime_error(exc) else: info("No NAMECHEAP_API_KEY environment variable is set yet.") print("The setup flow will validate the API key you enter, but it will not write the key to disk.") print() answer = input("Update saved username or validate a new API key? [y/N]: ").strip().lower() if answer not in ("y", "yes"): info("Keeping existing credentials.") return print() print("Enter your Namecheap credentials:\n") prompt = " API Username" if existing_user: prompt += f" [{existing_user}]" prompt += ": " api_user = input(prompt).strip() or existing_user or "" api_key = getpass.getpass(" API Key (hidden): ").strip() print() if not api_user or not api_key: err("Both username and API key are required.") sys.exit(1) save_config(api_user) success(f"Username saved to {CONFIG_FILE}") info("The API key was not written to disk. Export NAMECHEAP_API_KEY in your shell for future commands.") print("\nTesting API connection...") try: # Use the just-entered credentials directly for the validation call. os.environ["NAMECHEAP_API_USER"] = api_user os.environ["NAMECHEAP_API_KEY"] = api_key api_request("domains.getList", {"PageSize": "1"}) success("API connection successful!") except NamecheapError: print_safe_api_failure("API connection failed.", public_ip) except (urllib.error.URLError, OSError, ET.ParseError, json.JSONDecodeError) as exc: print_safe_runtime_error(exc) def cmd_domains_list(args): params = {"ListType": args.type, "Page": str(args.page), "PageSize": str(args.page_size)} if args.search: params["SearchTerm"] = args.search info("Fetching domain list...") root = api_request("domains.getList", params) print() print(f"{'DOMAIN':<30} {'EXPIRES':<12} {'LOCKED':<12} {'AUTO-RENEW':<10}") print(f"{'------':<30} {'-------':<12} {'------':<12} {'----------':<10}") for d in root.iter("Domain"): print("{:<30} {:<12} {:<12} {:<10}".format( d.attrib.get("Name", ""), d.attrib.get("Expires", ""), d.attrib.get("IsLocked", ""), d.attrib.get("AutoRenew", ""), )) print() def _print_hosts(root): print() print(f"{'HOST':<20} {'TYPE':<8} {'ADDRESS':<40} {'TTL':<8} {'MXPREF':<6}") print(f"{'----':<20} {'----':<8} {'-------':<40} {'---':<8} {'------':<6}") for h in root.iter("host"): print("{:<20} {:<8} {:<40} {:<8} {:<6}".format( h.attrib.get("Name", ""), h.attrib.get("Type", ""), h.attrib.get("Address", ""), h.attrib.get("TTL", "1800"), h.attrib.get("MXPref", "-"), )) print() def cmd_dns_get_hosts(args): sld, tld = parse_domain(args.domain) info(f"Fetching DNS records for {args.domain} (SLD={sld}, TLD={tld})...") root = api_request("domains.dns.getHosts", {"SLD": sld, "TLD": tld}) _print_hosts(root) def _existing_hosts(root): """Return existing host records as a list of dicts.""" records = [] for h in root.iter("host"): records.append({ "name": h.attrib.get("Name", ""), "type": h.attrib.get("Type", ""), "address": h.attrib.get("Address", ""), "ttl": h.attrib.get("TTL", "1800"), "mxpref": h.attrib.get("MXPref", ""), }) return records def _hosts_to_params(sld, tld, records): params = {"SLD": sld, "TLD": tld} i = 1 for r in records: if not (r["name"] and r["type"] and r["address"]): continue params[f"HostName{i}"] = r["name"] params[f"RecordType{i}"] = r["type"] params[f"Address{i}"] = r["address"] params[f"TTL{i}"] = r.get("ttl") or "1800" mxpref = r.get("mxpref") if mxpref not in (None, ""): # MX priority 0 is valid, so always send MXPref for MX records; # for other record types only forward a non-zero value. if r["type"].upper() == "MX" or mxpref != "0": params[f"MXPref{i}"] = mxpref i += 1 return params, i - 1 def cmd_dns_set_hosts(args): if not os.path.isfile(args.hosts): err(f"Hosts file not found: {args.hosts}") sys.exit(1) with open(args.hosts, "r", encoding="utf-8") as fh: raw = json.load(fh) records = [] for r in raw: records.append({ "name": r.get("HostName", ""), "type": r.get("RecordType", ""), "address": r.get("Address", ""), "ttl": str(r.get("TTL", "") or ""), "mxpref": str(r.get("MXPref", "") or ""), }) sld, tld = parse_domain(args.domain) params, count = _hosts_to_params(sld, tld, records) if count == 0: err(f"No valid host records found in {args.hosts}") sys.exit(1) info(f"Setting {count} DNS records for {args.domain}...") root = api_request("domains.dns.setHosts", params) if _attr(root, "DomainDNSSetHostsResult", "IsSuccess").lower() == "true": success(f"DNS records updated successfully for {args.domain}!") else: err("Failed to update DNS records.") sys.exit(1) def cmd_dns_add_host(args): sld, tld = parse_domain(args.domain) info(f"Fetching existing DNS records for {args.domain}...") root = api_request("domains.dns.getHosts", {"SLD": sld, "TLD": tld}) records = _existing_hosts(root) records.append({ "name": args.name, "type": args.type.upper(), "address": args.address, "ttl": args.ttl, "mxpref": args.mxpref or "", }) params, _ = _hosts_to_params(sld, tld, records) info(f"Adding {args.type.upper()} record: {args.name} -> {args.address}") result = api_request("domains.dns.setHosts", params) if _attr(result, "DomainDNSSetHostsResult", "IsSuccess").lower() == "true": success(f"DNS record added: {args.name} {args.type} {args.address}") else: err("Failed to add DNS record.") sys.exit(1) def cmd_dns_remove_host(args): sld, tld = parse_domain(args.domain) info(f"Fetching existing DNS records for {args.domain}...") root = api_request("domains.dns.getHosts", {"SLD": sld, "TLD": tld}) kept = [] removed = False for r in _existing_hosts(root): if (not removed and r["name"] == args.name and r["type"].upper() == args.type.upper() and (not args.address or r["address"] == args.address)): removed = True info(f"Removing record: {r['name']} {r['type']} {r['address']}") continue kept.append(r) if not removed: err("No matching record found to remove.") sys.exit(1) params, count = _hosts_to_params(sld, tld, kept) if count == 0: err("Cannot remove the last DNS record. Namecheap requires at least one record.") sys.exit(1) info(f"Updating DNS records for {args.domain}...") result = api_request("domains.dns.setHosts", params) if _attr(result, "DomainDNSSetHostsResult", "IsSuccess").lower() == "true": success("DNS record removed successfully!") else: err("Failed to remove DNS record.") sys.exit(1) def cmd_dns_get_list(args): sld, tld = parse_domain(args.domain) info(f"Fetching nameservers for {args.domain}...") root = api_request("domains.dns.getList", {"SLD": sld, "TLD": tld}) using = _attr(root, "DomainDNSGetListResult", "IsUsingOurDNS", "unknown") print() info(f"Using Namecheap DNS: {using}") print("\nNameservers:") for ns in root.iter("Nameserver"): if ns.text: print(f" - {ns.text.strip()}") print() def cmd_dns_set_default(args): sld, tld = parse_domain(args.domain) info(f"Setting {args.domain} to use Namecheap default DNS...") root = api_request("domains.dns.setDefault", {"SLD": sld, "TLD": tld}) if _attr(root, "DomainDNSSetDefaultResult", "Updated").lower() == "true": success(f"Domain {args.domain} now uses Namecheap default DNS!") else: err("Failed to set default DNS.") sys.exit(1) def cmd_dns_set_custom(args): sld, tld = parse_domain(args.domain) info(f"Setting {args.domain} to use custom nameservers: {args.nameservers}") root = api_request( "domains.dns.setCustom", {"SLD": sld, "TLD": tld, "Nameservers": args.nameservers}, ) if _attr(root, "DomainDNSSetCustomResult", "Updated").lower() == "true": success(f"Domain {args.domain} now uses custom nameservers!") else: err("Failed to set custom nameservers.") sys.exit(1) def cmd_dns_get_email_forwarding(args): info(f"Fetching email forwarding for {args.domain}...") root = api_request("domains.dns.getEmailForwarding", {"DomainName": args.domain}) print() print(f"{'MAILBOX':<20} {'FORWARDS TO':<40}") print(f"{'-------':<20} {'-----------':<40}") for fwd in root.iter("Forward"): mailbox = (fwd.attrib.get("mailbox") or fwd.attrib.get("MailBox") or fwd.attrib.get("Mailbox") or "") forward_to = (fwd.attrib.get("ForwardTo") or fwd.attrib.get("forwardto") or (fwd.text or "").strip()) print(f"{mailbox + '@' + args.domain:<20} {forward_to:<40}") print() def cmd_dns_set_email_forwarding(args): params = {"DomainName": args.domain} if args.mailbox and args.forward_to: params["MailBox1"] = args.mailbox params["ForwardTo1"] = args.forward_to elif args.forwards: if not os.path.isfile(args.forwards): err(f"Forwards file not found: {args.forwards}") sys.exit(1) with open(args.forwards, "r", encoding="utf-8") as fh: rules = json.load(fh) i = 1 for rule in rules: mailbox = rule.get("MailBox") or rule.get("mailbox") or "" forward_to = rule.get("ForwardTo") or rule.get("forwardto") or "" if mailbox and forward_to: params[f"MailBox{i}"] = mailbox params[f"ForwardTo{i}"] = forward_to i += 1 else: err("Provide either --mailbox/--forward-to or --forwards ") sys.exit(1) info(f"Setting email forwarding for {args.domain}...") root = api_request("domains.dns.setEmailForwarding", params) if _attr(root, "DomainDNSSetEmailForwardingResult", "IsSuccess").lower() == "true": success(f"Email forwarding updated for {args.domain}!") else: err("Failed to set email forwarding.") sys.exit(1) def cmd_ns_create(args): sld, tld = parse_domain(args.domain) info(f"Creating nameserver {args.nameserver} -> {args.ip}...") root = api_request( "domains.ns.create", {"SLD": sld, "TLD": tld, "Nameserver": args.nameserver, "IP": args.ip}, ) if _attr(root, "DomainNSCreateResult", "IsSuccess").lower() == "true": success(f"Nameserver {args.nameserver} created!") else: err("Failed to create nameserver.") sys.exit(1) def cmd_ns_delete(args): sld, tld = parse_domain(args.domain) info(f"Deleting nameserver {args.nameserver}...") root = api_request( "domains.ns.delete", {"SLD": sld, "TLD": tld, "Nameserver": args.nameserver}, ) if _attr(root, "DomainNSDeleteResult", "IsSuccess").lower() == "true": success(f"Nameserver {args.nameserver} deleted!") else: err("Failed to delete nameserver.") sys.exit(1) def cmd_ns_get_info(args): sld, tld = parse_domain(args.domain) info(f"Fetching info for nameserver {args.nameserver}...") root = api_request( "domains.ns.getInfo", {"SLD": sld, "TLD": tld, "Nameserver": args.nameserver}, ) ns_ip = _attr(root, "DomainNSInfoResult", "IP", "unknown") print() print(f"Nameserver: {args.nameserver}") print(f"IP Address: {ns_ip}") statuses = [s.text.strip() for s in root.iter("Status") if s.text and s.text.strip()] if statuses: print(f"Status: {', '.join(statuses)}") print() def cmd_ns_update(args): sld, tld = parse_domain(args.domain) info(f"Updating nameserver {args.nameserver}: {args.old_ip} -> {args.ip}...") root = api_request( "domains.ns.update", {"SLD": sld, "TLD": tld, "Nameserver": args.nameserver, "OldIP": args.old_ip, "IP": args.ip}, ) if _attr(root, "DomainNSUpdateResult", "IsSuccess").lower() == "true": success(f"Nameserver {args.nameserver} updated to {args.ip}!") else: err("Failed to update nameserver.") sys.exit(1) # --- Argument parsing ----------------------------------------------------- def build_parser(): parser = argparse.ArgumentParser( prog="namecheap.py", description="Namecheap DNS Management CLI", ) sub = parser.add_subparsers(dest="command", metavar="") sub.add_parser("setup", help="Configure API credentials and test connection").set_defaults(func=cmd_setup) sub.add_parser("public-ip", help="Show your public IP address").set_defaults(func=cmd_public_ip) p = sub.add_parser("domains.getList", help="List your Namecheap domains") p.add_argument("--type", default="ALL") p.add_argument("--search", default="") p.add_argument("--page", type=int, default=1) p.add_argument("--page-size", type=int, default=20) p.set_defaults(func=cmd_domains_list) p = sub.add_parser("domains.dns.getList", help="Get nameservers for a domain") p.add_argument("--domain", required=True) p.set_defaults(func=cmd_dns_get_list) p = sub.add_parser("domains.dns.getHosts", help="Get DNS records for a domain") p.add_argument("--domain", required=True) p.set_defaults(func=cmd_dns_get_hosts) p = sub.add_parser("domains.dns.setHosts", help="Set all DNS records (from JSON file)") p.add_argument("--domain", required=True) p.add_argument("--hosts", required=True) p.set_defaults(func=cmd_dns_set_hosts) p = sub.add_parser("domains.dns.setDefault", help="Use Namecheap default DNS") p.add_argument("--domain", required=True) p.set_defaults(func=cmd_dns_set_default) p = sub.add_parser("domains.dns.setCustom", help="Use custom nameservers") p.add_argument("--domain", required=True) p.add_argument("--nameservers", required=True) p.set_defaults(func=cmd_dns_set_custom) p = sub.add_parser("domains.dns.getEmailForwarding", help="Get email forwarding rules") p.add_argument("--domain", required=True) p.set_defaults(func=cmd_dns_get_email_forwarding) p = sub.add_parser("domains.dns.setEmailForwarding", help="Set email forwarding rules") p.add_argument("--domain", required=True) p.add_argument("--mailbox", default="") p.add_argument("--forward-to", default="") p.add_argument("--forwards", default="") p.set_defaults(func=cmd_dns_set_email_forwarding) p = sub.add_parser("domains.ns.create", help="Create a child nameserver (glue record)") p.add_argument("--domain", required=True) p.add_argument("--nameserver", required=True) p.add_argument("--ip", required=True) p.set_defaults(func=cmd_ns_create) p = sub.add_parser("domains.ns.delete", help="Delete a child nameserver") p.add_argument("--domain", required=True) p.add_argument("--nameserver", required=True) p.set_defaults(func=cmd_ns_delete) p = sub.add_parser("domains.ns.getInfo", help="Get nameserver info") p.add_argument("--domain", required=True) p.add_argument("--nameserver", required=True) p.set_defaults(func=cmd_ns_get_info) p = sub.add_parser("domains.ns.update", help="Update nameserver IP") p.add_argument("--domain", required=True) p.add_argument("--nameserver", required=True) p.add_argument("--old-ip", required=True) p.add_argument("--ip", required=True) p.set_defaults(func=cmd_ns_update) p = sub.add_parser("dns.addHost", help="Add a single DNS record (preserves existing)") p.add_argument("--domain", required=True) p.add_argument("--type", required=True) p.add_argument("--name", required=True) p.add_argument("--address", required=True) p.add_argument("--ttl", default="1800") p.add_argument("--mxpref", default="") p.set_defaults(func=cmd_dns_add_host) p = sub.add_parser("dns.removeHost", help="Remove a single DNS record") p.add_argument("--domain", required=True) p.add_argument("--type", required=True) p.add_argument("--name", required=True) p.add_argument("--address", default="") p.set_defaults(func=cmd_dns_remove_host) return parser def main(argv=None): parser = build_parser() args = parser.parse_args(argv) if not getattr(args, "command", None): parser.print_help() return 1 try: args.func(args) except NamecheapError as exc: print_safe_namecheap_error(exc) return 1 except urllib.error.URLError as exc: print_safe_runtime_error(exc) return 1 except (OSError, ET.ParseError, json.JSONDecodeError) as exc: print_safe_runtime_error(exc) return 1 return 0 if __name__ == "__main__": sys.exit(main())