Files
Andre 074fd22eff refactor: replace hidden module state with explicit data flow
The token expiry check read response headers that an earlier, unrelated
request had left behind in module state. Its correctness depended on call
order that nothing but a comment enforced, so inserting or reordering a
request would have silently checked the wrong response, or none at all.

The exit code had the mirrored problem: any function in the call tree could
raise a shared error count, so nothing at the top level revealed which steps
could contribute to a failing run.

Both now travel as return values. The ordering constraint becomes a data
dependency the interpreter enforces instead of a convention the next reader
has to know about.
2026-08-08 11:51:56 +02:00

724 lines
27 KiB
Python

#!/usr/bin/env python3
"""Mirror all GitHub repositories of a user into Gitea as pull mirrors.
Existing git mirrors are left to Gitea's own cron. This script creates the ones
that are missing, aligns their interval, and synchronizes GitHub release
metadata and assets because Gitea's native mirror does not. It never deletes
anything on the Gitea side -- retained history is the point of a backup.
"""
import json
import http.client
import io
import os
import re
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
import uuid
from collections import namedtuple
from datetime import datetime, timezone
KNOWN_KEYS = (
"GITHUB_USER",
"GITHUB_TOKEN",
"GITEA_URL",
"GITEA_TOKEN",
"GITEA_OWNER",
"MIRROR_INTERVAL",
"INCLUDE_FORKS",
"INCLUDE_ARCHIVED",
"SYNC_RELEASES",
"SYNC_RELEASE_ASSETS",
"DRY_RUN",
)
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.environ.get("MIRROR_ENV", os.path.join(SCRIPT_DIR, "mirror.env"))
Response = namedtuple("Response", "status body headers")
def log(level, msg):
print("%s [%s] %s" % (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), level, msg), flush=True)
def load_config():
cfg = {
"GITHUB_USER": "",
"GITHUB_TOKEN": "",
"GITEA_URL": "http://127.0.0.1:3000",
"GITEA_TOKEN": "",
"GITEA_OWNER": "",
"MIRROR_INTERVAL": "24h",
"INCLUDE_FORKS": "false",
"INCLUDE_ARCHIVED": "true",
"SYNC_RELEASES": "true",
"SYNC_RELEASE_ASSETS": "true",
"DRY_RUN": "false",
}
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
if key in KNOWN_KEYS:
cfg[key] = value.strip().strip('"').strip("'")
else:
log("WARN", "No config file at %s, relying on environment" % CONFIG_PATH)
for key in KNOWN_KEYS:
if os.environ.get(key):
cfg[key] = os.environ[key]
cfg["GITEA_URL"] = cfg["GITEA_URL"].rstrip("/")
return cfg
def truthy(value):
return str(value).strip().lower() in ("1", "true", "yes", "on")
def api(url, token=None, scheme="token", method="GET", payload=None):
"""Return a Response. Raises urllib.error.HTTPError on >=400."""
data = None
headers = {
"Accept": "application/json",
"User-Agent": "github-to-gitea-mirror/1.0",
}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = "%s %s" % (scheme, token)
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=180) as resp:
body = resp.read().decode("utf-8")
return Response(
resp.status,
json.loads(body) if body.strip() else None,
{k.lower(): v for k, v in resp.headers.items()},
)
def path_part(value):
"""Quote one URL path component, including slashes used inside tag names."""
return urllib.parse.quote(str(value), safe="")
class SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Do not forward API credentials to a cross-host download redirect."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
redirected = super().redirect_request(req, fp, code, msg, headers, newurl)
if redirected is None:
return None
old_host = urllib.parse.urlsplit(req.full_url).netloc.lower()
new_host = urllib.parse.urlsplit(newurl).netloc.lower()
if old_host != new_host:
redirected.remove_header("Authorization")
return redirected
safe_download_opener = urllib.request.build_opener(SafeRedirectHandler())
def download_github_asset(cfg, asset):
"""Download a release asset into a temporary file without buffering it in RAM."""
headers = {
"Accept": "application/octet-stream",
"User-Agent": "github-to-gitea-mirror/1.0",
}
if cfg["GITHUB_TOKEN"]:
headers["Authorization"] = "Bearer %s" % cfg["GITHUB_TOKEN"]
req = urllib.request.Request(asset["url"], headers=headers)
tmp = tempfile.TemporaryFile()
size = 0
try:
with safe_download_opener.open(req, timeout=180) as resp:
while True:
chunk = resp.read(1024 * 1024)
if not chunk:
break
tmp.write(chunk)
size += len(chunk)
expected = int(asset.get("size") or 0)
if expected and size != expected:
raise IOError("downloaded %d bytes, expected %d" % (size, expected))
tmp.seek(0)
return tmp, size
except Exception:
tmp.close()
raise
def upload_release_asset(cfg, repo_name, release_id, asset, source, size):
"""Stream a temporary file to Gitea as multipart/form-data."""
query = urllib.parse.urlencode({"name": asset["name"]})
url = ("%s/api/v1/repos/%s/%s/releases/%s/assets?%s"
% (cfg["GITEA_URL"], path_part(cfg["GITEA_OWNER"]), path_part(repo_name),
release_id, query))
parsed = urllib.parse.urlsplit(url)
boundary = "----github-to-gitea-mirror-%s" % uuid.uuid4().hex
safe_name = str(asset["name"]).replace("\\", "_").replace('"', "_")
safe_name = safe_name.replace("\r", "_").replace("\n", "_")
content_type = asset.get("content_type") or "application/octet-stream"
content_type = str(content_type).replace("\r", "").replace("\n", "")
preamble = (
"--%s\r\n"
"Content-Disposition: form-data; name=\"attachment\"; filename=\"%s\"\r\n"
"Content-Type: %s\r\n\r\n" % (boundary, safe_name, content_type)
).encode("utf-8")
ending = ("\r\n--%s--\r\n" % boundary).encode("ascii")
request_path = parsed.path + (("?" + parsed.query) if parsed.query else "")
if parsed.scheme == "https":
conn = http.client.HTTPSConnection(parsed.hostname, parsed.port, timeout=180)
elif parsed.scheme == "http":
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=180)
else:
raise ValueError("Unsupported Gitea URL scheme: %s" % parsed.scheme)
try:
conn.putrequest("POST", request_path)
conn.putheader("Authorization", "token %s" % cfg["GITEA_TOKEN"])
conn.putheader("Accept", "application/json")
conn.putheader("Content-Type", "multipart/form-data; boundary=%s" % boundary)
conn.putheader("Content-Length", str(len(preamble) + size + len(ending)))
conn.putheader("User-Agent", "github-to-gitea-mirror/1.0")
conn.endheaders()
conn.send(preamble)
while True:
chunk = source.read(1024 * 1024)
if not chunk:
break
conn.send(chunk)
conn.send(ending)
response = conn.getresponse()
body = response.read()
if response.status >= 400:
raise urllib.error.HTTPError(
url, response.status, response.reason, response.headers, io.BytesIO(body))
return json.loads(body.decode("utf-8")) if body.strip() else None
finally:
conn.close()
def github_repos(cfg):
"""Return (repos, headers of the last GitHub response).
The headers travel with the result because the token expiry check needs
them; keeping them here makes that dependency visible at the call site.
Private repos are included when a token is set.
"""
token = cfg["GITHUB_TOKEN"]
repos = []
headers = {}
page = 1
while True:
if token:
url = ("https://api.github.com/user/repos"
"?per_page=100&affiliation=owner&page=%d" % page)
else:
url = ("https://api.github.com/users/%s/repos"
"?per_page=100&type=owner&page=%d" % (cfg["GITHUB_USER"], page))
resp = api(url, token=token, scheme="Bearer")
headers = resp.headers
batch = resp.body
if not batch:
break
repos.extend(batch)
if len(batch) < 100:
break
page += 1
if token:
# affiliation=owner still returns repos owned by orgs the user belongs to
repos = [r for r in repos
if r["owner"]["login"].lower() == cfg["GITHUB_USER"].lower()]
return repos, headers
def warn_token_expiry(headers):
"""Fine-grained PATs always expire; a silent expiry would stall the mirror.
GitHub reports the date in a response header, so every run can check it.
Returns the number of errors found, so an expired token can fail the run.
"""
raw = headers.get("github-authentication-token-expiration")
if not raw:
return 0 # classic token without expiry, or no token at all
text = raw.strip().replace(" UTC", "").replace("Z", "").replace("T", " ")
parsed = None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S %z", "%Y-%m-%d"):
try:
parsed = datetime.strptime(text.split(".")[0], fmt)
break
except ValueError:
continue
if parsed is None:
return 0
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
days = (parsed.replace(tzinfo=None) - now_utc).days
if days < 0:
log("ERROR", "GITHUB_TOKEN expired on %s - renew it" % parsed.date())
return 1
if days <= 14:
log("WARN", "GITHUB_TOKEN expires in %d day(s), on %s - renew it"
% (days, parsed.date()))
else:
log("INFO", "GITHUB_TOKEN valid until %s (%d days)" % (parsed.date(), days))
return 0
def duration_seconds(value):
"""Parse a Go-style duration ('24h', '8h0m0s') into seconds. -1 if unparseable."""
if not value:
return -1
total = 0
matched = False
for amount, unit in re.findall(r"(\d+)\s*([hms])", str(value)):
matched = True
total += int(amount) * {"h": 3600, "m": 60, "s": 1}[unit]
return total if matched else -1
def gitea_repo(cfg, name):
try:
return api("%s/api/v1/repos/%s/%s" % (cfg["GITEA_URL"], cfg["GITEA_OWNER"], name),
token=cfg["GITEA_TOKEN"]).body
except urllib.error.HTTPError as exc:
if exc.code == 404:
return None
raise
def create_mirror(cfg, repo, dry_run):
name = repo["name"]
payload = {
"clone_addr": repo["clone_url"],
"repo_name": name,
"repo_owner": cfg["GITEA_OWNER"],
"mirror": True,
"mirror_interval": cfg["MIRROR_INTERVAL"],
"private": bool(repo.get("private")),
"description": (repo.get("description") or "")[:255],
"service": "github",
# Pull mirrors only ever sync git data, so importing the rest would be a
# one-off snapshot that silently goes stale. Keep it lean on the Pi.
"wiki": False,
"issues": False,
"labels": False,
"milestones": False,
"releases": False,
"pull_requests": False,
}
if cfg["GITHUB_TOKEN"]:
payload["auth_token"] = cfg["GITHUB_TOKEN"]
if dry_run:
log("DRY", "would mirror %s (private=%s)" % (name, payload["private"]))
return
api("%s/api/v1/repos/migrate" % cfg["GITEA_URL"], token=cfg["GITEA_TOKEN"],
method="POST", payload=payload)
log("OK", "mirrored %s (private=%s)" % (name, payload["private"]))
def align_interval(cfg, name, existing, dry_run):
wanted = duration_seconds(cfg["MIRROR_INTERVAL"])
current = duration_seconds(existing.get("mirror_interval"))
if wanted < 0 or current < 0 or wanted == current:
return
if dry_run:
log("DRY", "would set %s interval %s -> %s"
% (name, existing.get("mirror_interval"), cfg["MIRROR_INTERVAL"]))
return
api("%s/api/v1/repos/%s/%s" % (cfg["GITEA_URL"], cfg["GITEA_OWNER"], name),
token=cfg["GITEA_TOKEN"], method="PATCH",
payload={"mirror_interval": cfg["MIRROR_INTERVAL"]})
log("OK", "interval of %s set to %s" % (name, cfg["MIRROR_INTERVAL"]))
def github_releases(cfg, repo_name):
releases = []
page = 1
while True:
url = ("https://api.github.com/repos/%s/%s/releases?per_page=100&page=%d"
% (path_part(cfg["GITHUB_USER"]), path_part(repo_name), page))
batch = api(url, token=cfg["GITHUB_TOKEN"], scheme="Bearer").body
if not batch:
break
releases.extend(batch)
if len(batch) < 100:
break
page += 1
return releases
def github_release_assets(cfg, repo_name, release_id):
assets = []
page = 1
while True:
url = ("https://api.github.com/repos/%s/%s/releases/%s/assets"
"?per_page=100&page=%d"
% (path_part(cfg["GITHUB_USER"]), path_part(repo_name), release_id, page))
batch = api(url, token=cfg["GITHUB_TOKEN"], scheme="Bearer").body
if not batch:
break
assets.extend(batch)
if len(batch) < 100:
break
page += 1
return assets
def gitea_releases(cfg, repo_name):
releases = []
page = 1
while True:
url = ("%s/api/v1/repos/%s/%s/releases?limit=50&page=%d"
% (cfg["GITEA_URL"], path_part(cfg["GITEA_OWNER"]),
path_part(repo_name), page))
batch = api(url, token=cfg["GITEA_TOKEN"]).body
if not batch:
break
releases.extend(batch)
if len(batch) < 50:
break
page += 1
return releases
def gitea_tags(cfg, repo_name):
tags = set()
page = 1
while True:
url = ("%s/api/v1/repos/%s/%s/tags?limit=50&page=%d"
% (cfg["GITEA_URL"], path_part(cfg["GITEA_OWNER"]),
path_part(repo_name), page))
batch = api(url, token=cfg["GITEA_TOKEN"]).body
if not batch:
break
tags.update(tag["name"] for tag in batch)
if len(batch) < 50:
break
page += 1
return tags
def release_payload(release):
return {
"tag_name": release["tag_name"],
"target_commitish": release.get("target_commitish") or "",
"name": release.get("name") or "",
"body": release.get("body") or "",
"draft": bool(release.get("draft")),
"prerelease": bool(release.get("prerelease")),
}
def release_needs_update(source, target):
"""Return true for fields Gitea's PATCH endpoint can actually change."""
wanted = release_payload(source)
if bool(target.get("draft")) != wanted["draft"]:
return True
if bool(target.get("prerelease")) != wanted["prerelease"]:
return True
# Gitea ignores empty strings in release PATCH requests. Retaining old text
# also follows this script's policy of never deleting backup information.
if wanted["name"] and (target.get("name") or "") != wanted["name"]:
return True
if wanted["body"] and (target.get("body") or "") != wanted["body"]:
return True
return False
def release_update_payload(source):
wanted = release_payload(source)
payload = {
"draft": wanted["draft"],
"prerelease": wanted["prerelease"],
}
for key in ("target_commitish", "name", "body"):
if wanted[key]:
payload[key] = wanted[key]
return payload
def sync_release_assets(cfg, repo_name, source_release, target_release, dry_run):
"""Return (copied, preserved, failed) counts for this release's assets."""
copied = preserved = failed = 0
source_assets = github_release_assets(cfg, repo_name, source_release["id"])
target_assets = {asset["name"]: asset for asset in target_release.get("assets", [])}
for asset in source_assets:
name = asset["name"]
existing = target_assets.get(name)
if existing is not None:
source_size = int(asset.get("size") or 0)
target_size = int(existing.get("size") or 0)
if source_size and target_size and source_size != target_size:
preserved += 1
log("WARN", "%s release %s asset %s differs (%d vs %d bytes) - "
"Gitea copy left untouched"
% (repo_name, source_release["tag_name"], name,
source_size, target_size))
continue
if dry_run:
log("DRY", "would copy %s release %s asset %s (%d bytes)"
% (repo_name, source_release["tag_name"], name,
int(asset.get("size") or 0)))
copied += 1
continue
tmp = None
try:
tmp, size = download_github_asset(cfg, asset)
upload_release_asset(cfg, repo_name, target_release["id"], asset, tmp, size)
copied += 1
log("OK", "copied %s release %s asset %s (%d bytes)"
% (repo_name, source_release["tag_name"], name, size))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:300]
log("ERROR", "%s release %s asset %s failed (HTTP %s): %s"
% (repo_name, source_release["tag_name"], name, exc.code, detail))
failed += 1
except Exception as exc:
log("ERROR", "%s release %s asset %s failed: %s"
% (repo_name, source_release["tag_name"], name, exc))
failed += 1
finally:
if tmp is not None:
tmp.close()
return copied, preserved, failed
def sync_releases(cfg, repo_name, dry_run):
stats = {"created": 0, "updated": 0, "assets": 0, "preserved": 0,
"waiting": 0, "errors": 0}
source_releases = github_releases(cfg, repo_name)
target_releases = gitea_releases(cfg, repo_name)
by_tag = {release["tag_name"]: release for release in target_releases}
tags_to_check = [
release["tag_name"] for release in source_releases
if not release.get("draft")
and (release["tag_name"] not in by_tag
or by_tag[release["tag_name"]].get("draft"))
]
available_tags = gitea_tags(cfg, repo_name) if tags_to_check else set()
source_tags = {release["tag_name"] for release in source_releases}
orphaned = sorted(tag for tag in by_tag if tag not in source_tags)
if orphaned:
log("WARN", "%s Gitea release(s) with no GitHub counterpart left untouched: %s"
% (repo_name, ", ".join(orphaned)))
for source in source_releases:
tag = source["tag_name"]
target = by_tag.get(tag)
needs_mirrored_tag = (not source.get("draft")
and (target is None or target.get("draft")))
if needs_mirrored_tag and tag not in available_tags:
stats["waiting"] += 1
log("WARN", "%s release %s waits for its tag to reach the Gitea mirror"
% (repo_name, tag))
continue
try:
if target is None:
if dry_run:
target = {"id": source["id"], "tag_name": tag, "assets": []}
log("DRY", "would create %s release %s" % (repo_name, tag))
else:
target = api(
"%s/api/v1/repos/%s/%s/releases"
% (cfg["GITEA_URL"], path_part(cfg["GITEA_OWNER"]),
path_part(repo_name)),
token=cfg["GITEA_TOKEN"], method="POST",
payload=release_payload(source)).body
log("OK", "created %s release %s" % (repo_name, tag))
stats["created"] += 1
elif release_needs_update(source, target):
if dry_run:
log("DRY", "would update %s release %s" % (repo_name, tag))
else:
target = api(
"%s/api/v1/repos/%s/%s/releases/%s"
% (cfg["GITEA_URL"], path_part(cfg["GITEA_OWNER"]),
path_part(repo_name), target["id"]),
token=cfg["GITEA_TOKEN"], method="PATCH",
payload=release_update_payload(source)).body
log("OK", "updated %s release %s" % (repo_name, tag))
stats["updated"] += 1
if truthy(cfg["SYNC_RELEASE_ASSETS"]):
copied, preserved, failed = sync_release_assets(
cfg, repo_name, source, target, dry_run)
stats["assets"] += copied
stats["preserved"] += preserved
stats["errors"] += failed
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:300]
log("ERROR", "%s release %s failed (HTTP %s): %s"
% (repo_name, tag, exc.code, detail))
stats["errors"] += 1
except Exception as exc:
log("ERROR", "%s release %s failed: %s" % (repo_name, tag, exc))
stats["errors"] += 1
return stats
def main():
cfg = load_config()
dry_run = truthy(cfg["DRY_RUN"])
missing = [k for k in ("GITHUB_USER", "GITEA_URL", "GITEA_TOKEN", "GITEA_OWNER")
if not cfg[k]]
if missing:
log("ERROR", "Missing config value(s): %s" % ", ".join(missing))
return 2
if not cfg["GITHUB_TOKEN"]:
log("WARN", "GITHUB_TOKEN is empty - private repositories will be skipped")
try:
repos, github_headers = github_repos(cfg)
except urllib.error.HTTPError as exc:
log("ERROR", "GitHub API %s: %s" % (exc.code, exc.read().decode("utf-8", "replace")[:300]))
return 2
except Exception as exc:
log("ERROR", "GitHub API unreachable: %s" % exc)
return 2
errors = warn_token_expiry(github_headers)
if not truthy(cfg["INCLUDE_FORKS"]):
repos = [r for r in repos if not r.get("fork")]
if not truthy(cfg["INCLUDE_ARCHIVED"]):
repos = [r for r in repos if not r.get("archived")]
private_count = sum(1 for r in repos if r.get("private"))
log("INFO", "GitHub: %d repositor%s to consider (%d public, %d private)%s"
% (len(repos), "y" if len(repos) == 1 else "ies",
len(repos) - private_count, private_count,
" (DRY RUN)" if dry_run else ""))
# A fine-grained token defaults to "Public repositories", which looks exactly
# like an account without private repos. Name the likely cause explicitly.
if cfg["GITHUB_TOKEN"] and private_count == 0:
log("WARN", "Token is set but GitHub reports no private repositories - "
"check that its repository access is 'All repositories', "
"not 'Public repositories'")
created = skipped = 0
release_totals = {"created": 0, "updated": 0, "assets": 0,
"preserved": 0, "waiting": 0}
for repo in sorted(repos, key=lambda r: r["name"].lower()):
name = repo["name"]
try:
existing = gitea_repo(cfg, name)
except Exception as exc:
log("ERROR", "%s: cannot query Gitea: %s" % (name, exc))
errors += 1
continue
if existing is None:
mirror_created = False
try:
create_mirror(cfg, repo, dry_run)
created += 1
mirror_created = True
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:300]
log("ERROR", "%s: migrate failed (HTTP %s): %s" % (name, exc.code, detail))
errors += 1
except Exception as exc:
log("ERROR", "%s: migrate failed: %s" % (name, exc))
errors += 1
if mirror_created and truthy(cfg["SYNC_RELEASES"]):
try:
if dry_run:
count = len(github_releases(cfg, name))
if count:
log("DRY", "would synchronize %d release(s) after creating %s"
% (count, name))
else:
stats = sync_releases(cfg, name, dry_run)
for key in release_totals:
release_totals[key] += stats[key]
errors += stats["errors"]
except Exception as exc:
log("ERROR", "%s: could not synchronize releases: %s" % (name, exc))
errors += 1
continue
skipped += 1
if not existing.get("mirror"):
log("WARN", "%s exists in Gitea but is not a mirror - left untouched" % name)
continue
try:
align_interval(cfg, name, existing, dry_run)
except Exception as exc:
log("ERROR", "%s: could not update mirror interval: %s" % (name, exc))
errors += 1
if truthy(cfg["SYNC_RELEASES"]):
try:
stats = sync_releases(cfg, name, dry_run)
for key in release_totals:
release_totals[key] += stats[key]
errors += stats["errors"]
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:300]
log("ERROR", "%s: release sync failed (HTTP %s): %s"
% (name, exc.code, detail))
errors += 1
except Exception as exc:
log("ERROR", "%s: release sync failed: %s" % (name, exc))
errors += 1
github_names = {r["name"].lower() for r in repos}
try:
page, orphans = 1, []
while True:
batch = api("%s/api/v1/user/repos?limit=50&page=%d" % (cfg["GITEA_URL"], page),
token=cfg["GITEA_TOKEN"]).body
if not batch:
break
orphans.extend(
r["name"] for r in batch
if r.get("mirror")
and r["owner"]["login"].lower() == cfg["GITEA_OWNER"].lower()
and r["name"].lower() not in github_names
)
if len(batch) < 50:
break
page += 1
if orphans:
log("WARN", "Mirrors with no GitHub counterpart (renamed or deleted upstream): %s"
% ", ".join(sorted(orphans)))
except Exception as exc:
log("WARN", "Could not check for orphaned mirrors: %s" % exc)
log("INFO", "Done: %d mirror(s) created, %d already present; releases: "
"%d created, %d updated, %d asset(s) copied, %d preserved, "
"%d waiting for tags; %d error(s)"
% (created, skipped, release_totals["created"], release_totals["updated"],
release_totals["assets"], release_totals["preserved"],
release_totals["waiting"], errors))
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())