From a88bd44cde92bd000a6334949b192a89b8173954 Mon Sep 17 00:00:00 2001 From: Luke Eltiste Date: Tue, 14 Apr 2026 17:36:24 -0700 Subject: [PATCH 1/3] refactor: package backup runner and improve sync behavior --- Dockerfile | 10 +- backup.sh | 27 ++--- config.json.example | 7 +- github-backup/__init__.py | 0 github-backup/github-backup.py | 150 ------------------------- github_backup/__init__.py | 8 ++ github_backup/__main__.py | 4 + github_backup/cli.py | 196 +++++++++++++++++++++++++++++++++ github_backup/config.py | 112 +++++++++++++++++++ github_backup/git_ops.py | 107 ++++++++++++++++++ github_backup/github_api.py | 72 ++++++++++++ pyproject.toml | 22 ++-- 12 files changed, 531 insertions(+), 184 deletions(-) delete mode 100644 github-backup/__init__.py delete mode 100644 github-backup/github-backup.py create mode 100644 github_backup/__init__.py create mode 100644 github_backup/__main__.py create mode 100644 github_backup/cli.py create mode 100644 github_backup/config.py create mode 100644 github_backup/git_ops.py create mode 100644 github_backup/github_api.py diff --git a/Dockerfile b/Dockerfile index 3c25404..6f507f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.13-slim # Set work directory -WORKDIR /home/docker +WORKDIR /home/docker/github-backup # Install system dependencies including git RUN apt-get update && \ @@ -10,16 +10,14 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* # Copy the necessary files -COPY github-backup/ ./github-backup/ -COPY pyproject.toml . -COPY config.json.example ./github-backup/ -COPY backup.sh . +COPY github_backup/ ./github_backup/ +COPY pyproject.toml README.md backup.sh config.json.example ./ # Install project dependencies RUN pip install --no-cache-dir -e . # Set permissions -RUN chmod -R 777 /home/docker && \ +RUN chmod -R 775 /home/docker && \ chown -R 99:100 /home/docker # Use the non-root user to run the container diff --git a/backup.sh b/backup.sh index ea8572c..9ad8cd7 100644 --- a/backup.sh +++ b/backup.sh @@ -1,28 +1,21 @@ #!/bin/sh +set -eu + echo "Project: github-backup" echo "Author: Frazzer951" echo "Base: Python 3.13-slim" echo "Target: Unraid" echo "" -# If config doesn't exist yet, create it -if [ ! -f /home/docker/github-backup/config/config.json ]; then - cp /home/docker/github-backup/config.json.example /home/docker/github-backup/config/config.json -fi - -# Update config.json -cp /home/docker/github-backup/config/config.json /home/docker/github-backup/config.json +APP_ROOT=/home/docker/github-backup +CONFIG_DIR=${CONFIG_DIR:-$APP_ROOT/config} +CONFIG_PATH=${CONFIG_PATH:-$CONFIG_DIR/config.json} -# Update token in config.json match $TOKEN environment variable -sed -i '/token/c\ \"token\" : \"'${TOKEN}'\",' /home/docker/github-backup/config.json +mkdir -p "$CONFIG_DIR" -# Return config.json to persistant volume -cp /home/docker/github-backup/config.json /home/docker/github-backup/config/config.json +if [ ! -f "$CONFIG_PATH" ]; then + cp "$APP_ROOT/config.json.example" "$CONFIG_PATH" +fi -# Start backup -while true; do - python3 ./github-backup/github-backup.py /home/docker/github-backup/config/config.json - chown -R 99:100 /home/docker/backups - sleep $SCHEDULE -done +exec python3 -m github_backup "$CONFIG_PATH" --loop diff --git a/config.json.example b/config.json.example index f14b1c7..8b916c2 100644 --- a/config.json.example +++ b/config.json.example @@ -1,4 +1,9 @@ { "token": "0123456789abcdef0123456789abcdef", - "directory": "/home/docker/backups" + "directory": "/home/docker/backups", + "concurrency": 4, + "schedule_seconds": 86400, + "failure_delay_seconds": 300, + "extra_orgs": [], + "owners": [] } diff --git a/github-backup/__init__.py b/github-backup/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/github-backup/github-backup.py b/github-backup/github-backup.py deleted file mode 100644 index abc2635..0000000 --- a/github-backup/github-backup.py +++ /dev/null @@ -1,150 +0,0 @@ -import argparse -import errno -import json -import os -import re -import subprocess -import sys -import time -from collections.abc import Iterator -from urllib.parse import urlparse, urlunparse - -import requests -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry - - -def get_json(url: str, session: requests.Session) -> Iterator[dict]: - while True: - try: - response = session.get(url, timeout=10) - response.raise_for_status() - - yield response.json() - - if "next" not in response.links: - break - url = response.links["next"]["url"] - - except requests.exceptions.HTTPError as e: - status_code = e.response.status_code - if ( - status_code == 403 - and "X-RateLimit-Remaining" in e.response.headers - and e.response.headers["X-RateLimit-Remaining"] == "0" - ): - reset_time = int(e.response.headers["X-RateLimit-Reset"]) - wait_seconds = reset_time - int(time.time()) + 10 # Adding a buffer - print(f"Rate limit exceeded. Waiting for {wait_seconds} seconds.", file=sys.stderr) - time.sleep(wait_seconds) - continue - elif 400 <= status_code < 500: - print(f"Client error: {e}", file=sys.stderr) - break - else: - print(f"Server error: {e}", file=sys.stderr) - time.sleep(5) # Wait a bit before retrying - continue - except requests.exceptions.RequestException as e: - print(f"Error fetching data from {url}: {e}", file=sys.stderr) - break - - -def check_name(name: str) -> str: - pattern = r"^\w[-\.\w]*$" - if not re.match(pattern, name): - raise ValueError(f"Invalid name '{name}'") - return name - - -def mkdir(path: str) -> bool: - try: - os.makedirs(path, mode=0o770) - return True - except OSError as e: - if e.errno != errno.EEXIST: - raise - return False - - -def prepare_repo_url(repo_url: str, username: str, token: str) -> str: - parsed = urlparse(repo_url) - modified = list(parsed) - modified[1] = f"{username}:{token}@{parsed.netloc}" - return urlunparse(modified) - - -def init_bare_repo(repo_path: str) -> None: - subprocess.run(["git", "init", "--bare", "--quiet"], cwd=repo_path, check=True) - - -def fetch_repo(repo_path: str, repo_url: str) -> None: - subprocess.run( - [ - "git", - "fetch", - "--force", - "--prune", - "--tags", - repo_url, - "refs/heads/*:refs/heads/*", - ], - cwd=repo_path, - check=True, - ) - - -def mirror(repo_name: str, repo_url: str, to_path: str, username: str, token: str) -> tuple[str, str]: - repo_path = os.path.join(to_path, repo_name) - os.makedirs(repo_path, exist_ok=True) - - init_bare_repo(repo_path) - - authenticated_repo_url = prepare_repo_url(repo_url, username, token) - fetch_repo(repo_path, authenticated_repo_url) - - return repo_name, repo_url.split("/")[-2] - - -def create_session(retries: int = 3, backoff_factor: float = 0.3) -> requests.Session: - session = requests.Session() - retry = Retry(total=retries, backoff_factor=backoff_factor, status_forcelist=[500, 502, 503, 504]) - adapter = HTTPAdapter(max_retries=retry) - session.mount("http://", adapter) - session.mount("https://", adapter) - return session - - -def main(): - parser = argparse.ArgumentParser(description="Backup GitHub repositories") - parser.add_argument("config", metavar="CONFIG", help="a configuration file") - args = parser.parse_args() - - with open(args.config) as f: - config = json.load(f) - - owners: list[str] | None = config.get("owners") - token: str = config["token"] - path: str = os.path.expanduser(config["directory"]) - if mkdir(path): - print(f"Created directory {path}", file=sys.stderr) - - with create_session() as session: - session.headers.update({"Authorization": f"token {token}"}) - user: dict = next(get_json("https://api.github.com/user", session)) - for page in get_json("https://api.github.com/user/repos", session): - for repo in page: - name: str = check_name(repo["name"]) - owner: str = check_name(repo["owner"]["login"]) - clone_url: str = repo["clone_url"] - - if owners and owner not in owners: - continue - - owner_path: str = os.path.join(path, owner) - os.makedirs(owner_path, exist_ok=True) - mirror(name, clone_url, owner_path, user["login"], token) - - -if __name__ == "__main__": - main() diff --git a/github_backup/__init__.py b/github_backup/__init__.py new file mode 100644 index 0000000..b51bd8a --- /dev/null +++ b/github_backup/__init__.py @@ -0,0 +1,8 @@ +"""GitHub backup package.""" + +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("github_backup") +except PackageNotFoundError: + __version__ = "0.0.0" diff --git a/github_backup/__main__.py b/github_backup/__main__.py new file mode 100644 index 0000000..1bfefd5 --- /dev/null +++ b/github_backup/__main__.py @@ -0,0 +1,4 @@ +from github_backup.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/github_backup/cli.py b/github_backup/cli.py new file mode 100644 index 0000000..28ba4bf --- /dev/null +++ b/github_backup/cli.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import argparse +import logging +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +from github_backup.config import AppConfig, ConfigError, load_config +from github_backup.git_ops import MirrorResult, git_credentials_env, mirror_repo +from github_backup.github_api import Repo, create_session, iter_organization_repositories, iter_repositories + +logger = logging.getLogger("github_backup") + + +def configure_logging(verbose: bool) -> None: + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s %(levelname)s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Backup GitHub repositories as bare Git repositories") + parser.add_argument( + "config", + nargs="?", + default="config.json", + help="Path to the JSON config file (default: %(default)s)", + ) + parser.add_argument("--loop", action="store_true", help="Run continuously using the configured schedule") + parser.add_argument("--verbose", action="store_true", help="Enable debug logging") + return parser.parse_args(argv) + + +def _filter_repositories(repositories: list[Repo], config: AppConfig) -> list[Repo]: + if not config.owners: + return repositories + return [repo for repo in repositories if repo.owner in config.owners] + + +def _load_repositories(config: AppConfig) -> list[Repo]: + seen: set[tuple[str, str]] = set() + repositories: list[Repo] = [] + + with create_session(config.token) as session: + for repo in iter_repositories(session): + key = (repo.owner, repo.name) + if key in seen: + continue + seen.add(key) + repositories.append(repo) + + for organization in sorted(config.extra_orgs): + logger.info("Loading repositories for extra org %s", organization) + for repo in iter_organization_repositories(session, organization): + key = (repo.owner, repo.name) + if key in seen: + continue + seen.add(key) + repositories.append(repo) + + return _filter_repositories(repositories, config) + + +def _warn_on_legacy_nested_layout(destination: Path) -> None: + legacy_root = destination / destination.name + if legacy_root.exists() and legacy_root.is_dir(): + logger.warning( + "Detected legacy nested backup layout at %s. New runs will use %s directly.", + legacy_root, + destination, + ) + + +def run_backup(config: AppConfig) -> int: + config.directory.mkdir(parents=True, exist_ok=True) + _warn_on_legacy_nested_layout(config.directory) + + repositories = _load_repositories(config) + + if not repositories: + logger.info("No repositories matched the configured filters") + return 0 + + logger.info( + "Syncing %s repositories into %s with concurrency=%s", + len(repositories), + config.directory, + config.concurrency, + ) + + results: list[MirrorResult] = [] + failures = 0 + completed = 0 + changed_count = 0 + unchanged_count = 0 + total = len(repositories) + + with git_credentials_env(config.token) as git_env: + with ThreadPoolExecutor(max_workers=config.concurrency) as executor: + future_map = { + executor.submit( + mirror_repo, + repo.name, + repo.owner, + repo.clone_url, + config.directory, + env=git_env, + ): repo + for repo in repositories + } + + for future in as_completed(future_map): + repo = future_map[future] + completed += 1 + remaining = total - completed + try: + result = future.result() + except Exception as exc: # noqa: BLE001 + failures += 1 + logger.error( + "[%s/%s, %s remaining] Failed to sync %s/%s: %s", + completed, + total, + remaining, + repo.owner, + repo.name, + exc, + ) + continue + + results.append(result) + if result.changed: + changed_count += 1 + status = "new" if result.created else "updated" + logger.info( + "[%s/%s, %s remaining] Synced %s/%s (%s) in %.2fs", + completed, + total, + remaining, + result.owner, + result.repo, + status, + result.duration_seconds, + ) + if result.details: + logger.info("%s", result.details) + else: + unchanged_count += 1 + + created_count = sum(1 for result in results if result.created) + updated_count = sum(1 for result in results if result.changed and not result.created) + logger.info( + "Backup finished: %s ok, %s created, %s updated, %s unchanged, %s failed", + len(results), + created_count, + updated_count, + unchanged_count, + failures, + ) + return 1 if failures else 0 + + +def run_loop(config: AppConfig) -> int: + while True: + started_at = time.monotonic() + try: + exit_code = run_backup(config) + except Exception: # noqa: BLE001 + logger.exception("Backup run crashed") + exit_code = 1 + + delay = config.schedule_seconds if exit_code == 0 else config.failure_delay_seconds + elapsed = max(0, time.monotonic() - started_at) + sleep_for = max(1, int(delay - elapsed)) + logger.info("Next run in %s seconds", sleep_for) + time.sleep(sleep_for) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + configure_logging(args.verbose) + + try: + config = load_config(args.config) + except ConfigError as exc: + logger.error("%s", exc) + return 2 + + if args.loop: + run_loop(config) + return 0 + return run_backup(config) diff --git a/github_backup/config.py b/github_backup/config.py new file mode 100644 index 0000000..07f06b4 --- /dev/null +++ b/github_backup/config.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path + +DEFAULT_CONCURRENCY = 4 +DEFAULT_FAILURE_DELAY_SECONDS = 300 +DEFAULT_SCHEDULE_SECONDS = 86400 + + +class ConfigError(ValueError): + """Raised when configuration is invalid.""" + + +@dataclass(slots=True) +class AppConfig: + token: str + directory: Path + owners: frozenset[str] | None + extra_orgs: frozenset[str] + concurrency: int + schedule_seconds: int + failure_delay_seconds: int + + +def _read_json(path: Path) -> dict: + if not path.exists(): + raise ConfigError(f"Config file does not exist: {path}") + + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + + if not isinstance(payload, dict): + raise ConfigError("Config file must contain a JSON object") + return payload + + +def _read_positive_int(value: object, *, field_name: str) -> int: + if isinstance(value, str): + value = value.strip() + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise ConfigError(f"{field_name} must be an integer") from exc + + if parsed <= 0: + raise ConfigError(f"{field_name} must be greater than zero") + return parsed + + +def _read_name_list(value: object, *, field_name: str) -> frozenset[str] | None: + if value in (None, "", []): + return None + + if isinstance(value, str): + items = [item.strip() for item in value.split(",")] + elif isinstance(value, list): + items = [str(item).strip() for item in value] + else: + raise ConfigError(f"{field_name} must be a list of strings or a comma-separated string") + + names = frozenset(item for item in items if item) + return names or None + + +def load_config(config_path: str | Path) -> AppConfig: + path = Path(config_path) + payload = _read_json(path) + + token = os.environ.get("TOKEN") or os.environ.get("GITHUB_TOKEN") or payload.get("token", "") + token = str(token).strip() + if not token: + raise ConfigError("GitHub token is required via config or TOKEN/GITHUB_TOKEN") + + directory_value = os.environ.get("BACKUP_DIRECTORY") or payload.get("directory", "") + directory = Path(str(directory_value)).expanduser() + if not str(directory).strip(): + raise ConfigError("Backup directory is required via config or BACKUP_DIRECTORY") + + owners = _read_name_list(os.environ.get("OWNERS") or payload.get("owners"), field_name="owners") + extra_orgs = _read_name_list( + os.environ.get("EXTRA_ORGS") or payload.get("extra_orgs"), + field_name="extra_orgs", + ) or frozenset() + + concurrency = _read_positive_int( + os.environ.get("CONCURRENCY", payload.get("concurrency", DEFAULT_CONCURRENCY)), + field_name="concurrency", + ) + schedule_seconds = _read_positive_int( + os.environ.get("SCHEDULE", payload.get("schedule_seconds", DEFAULT_SCHEDULE_SECONDS)), + field_name="schedule_seconds", + ) + failure_delay_seconds = _read_positive_int( + os.environ.get( + "FAILURE_DELAY_SECONDS", + payload.get("failure_delay_seconds", DEFAULT_FAILURE_DELAY_SECONDS), + ), + field_name="failure_delay_seconds", + ) + + return AppConfig( + token=token, + directory=directory, + owners=owners, + extra_orgs=extra_orgs, + concurrency=concurrency, + schedule_seconds=schedule_seconds, + failure_delay_seconds=failure_delay_seconds, + ) diff --git a/github_backup/git_ops.py b/github_backup/git_ops.py new file mode 100644 index 0000000..2439e8a --- /dev/null +++ b/github_backup/git_ops.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import os +import stat +import subprocess +import tempfile +import textwrap +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from time import monotonic + +FETCH_REFSPEC = "+refs/heads/*:refs/heads/*" + + +@dataclass(frozen=True, slots=True) +class MirrorResult: + repo: str + owner: str + created: bool + changed: bool + duration_seconds: float + details: str = "" + + +def _run_git(args: list[str], *, cwd: Path, env: dict[str, str]) -> str: + result = subprocess.run( + ["git", *args], + cwd=cwd, + env=env, + check=True, + capture_output=True, + text=True, + ) + return _normalize_git_output(result.stdout, result.stderr) + + +def _normalize_git_output(stdout: str, stderr: str) -> str: + lines = [line.rstrip() for line in f"{stdout}\n{stderr}".splitlines() if line.strip()] + return "\n".join(lines) + + +@contextmanager +def git_credentials_env(token: str) -> Iterator[dict[str, str]]: + with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False, encoding="utf-8") as handle: + handle.write( + textwrap.dedent( + """\ + #!/bin/sh + case "$1" in + *Username*) printf '%s\n' "$GITHUB_BACKUP_GIT_USERNAME" ;; + *Password*) printf '%s\n' "$GITHUB_BACKUP_GIT_PASSWORD" ;; + *) exit 1 ;; + esac + """ + ) + ) + helper_path = Path(handle.name) + + helper_path.chmod(helper_path.stat().st_mode | stat.S_IEXEC) + + env = os.environ.copy() + env.update( + { + "GIT_ASKPASS": str(helper_path), + "GIT_TERMINAL_PROMPT": "0", + "GITHUB_BACKUP_GIT_USERNAME": "x-access-token", + "GITHUB_BACKUP_GIT_PASSWORD": token, + } + ) + + try: + yield env + finally: + helper_path.unlink(missing_ok=True) + + +def mirror_repo(repo_name: str, owner: str, clone_url: str, destination: Path, *, env: dict[str, str]) -> MirrorResult: + owner_path = destination / owner + owner_path.mkdir(parents=True, exist_ok=True) + repo_path = owner_path / repo_name + + created = not (repo_path / "config").exists() + started = monotonic() + + if created: + _run_git(["clone", "--bare", "--origin", "origin", clone_url, repo_name], cwd=owner_path, env=env) + changed = True + details = "" + else: + _run_git(["remote", "set-url", "origin", clone_url], cwd=repo_path, env=env) + details = _run_git( + ["fetch", "--force", "--prune", "--tags", "origin", FETCH_REFSPEC], + cwd=repo_path, + env=env, + ) + changed = bool(details) + + return MirrorResult( + repo=repo_name, + owner=owner, + created=created, + changed=changed, + duration_seconds=monotonic() - started, + details=details, + ) diff --git a/github_backup/github_api.py b/github_backup/github_api.py new file mode 100644 index 0000000..75ea7f8 --- /dev/null +++ b/github_backup/github_api.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +API_ROOT = "https://api.github.com" + + +@dataclass(frozen=True, slots=True) +class Repo: + name: str + owner: str + clone_url: str + + +def create_session(token: str, retries: int = 3, backoff_factor: float = 0.5) -> requests.Session: + session = requests.Session() + retry = Retry( + total=retries, + backoff_factor=backoff_factor, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["GET"], + respect_retry_after_header=True, + ) + adapter = HTTPAdapter(max_retries=retry) + session.mount("http://", adapter) + session.mount("https://", adapter) + session.headers.update( + { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + } + ) + return session + + +def iter_repositories(session: requests.Session) -> Iterator[Repo]: + yield from _iter_repo_collection( + session, + f"{API_ROOT}/user/repos", + {"per_page": 100, "affiliation": "owner,collaborator,organization_member"}, + ) + + +def iter_organization_repositories(session: requests.Session, organization: str) -> Iterator[Repo]: + yield from _iter_repo_collection( + session, + f"{API_ROOT}/orgs/{organization}/repos", + {"per_page": 100, "type": "all"}, + ) + + +def _iter_repo_collection(session: requests.Session, url: str, params: dict[str, str | int]) -> Iterator[Repo]: + while url: + response = session.get(url, params=params, timeout=30) + response.raise_for_status() + + for item in response.json(): + yield Repo( + name=item["name"], + owner=item["owner"]["login"], + clone_url=item["clone_url"], + ) + + params = None + next_link = response.links.get("next") + url = next_link["url"] if next_link else "" diff --git a/pyproject.toml b/pyproject.toml index 671a7a0..a470c97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,22 @@ [project] name = "github_backup" -version = "1.0.0" +dynamic = ["version"] requires-python = ">=3.13,<3.14" -dependencies = [ - "requests~=2.32.3" -] +dependencies = ["requests~=2.33"] -[project.optional-dependencies] -dev = [ - "ruff" -] +[project.scripts] +github-backup = "github_backup.cli:main" + +[dependency-groups] +dev = ["ruff>=0.15.10"] [build-system] -requires = ["setuptools"] +requires = ["setuptools>=64", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" +[tool.setuptools_scm] +fallback_version = "0.0.0" + [tool.setuptools.packages.find] include = ["github_backup"] @@ -22,7 +24,7 @@ include = ["github_backup"] exclude = [".venv", ".vscode", "venv"] line-length = 120 indent-width = 4 -target-version = "py311" +target-version = "py313" [tool.ruff.lint] select = ["E", "W", "F", "I", "UP", "B"] From 3ff5ef6346f044f04e964c04bab534e6848af752 Mon Sep 17 00:00:00 2001 From: Luke Eltiste Date: Tue, 14 Apr 2026 17:36:56 -0700 Subject: [PATCH 2/3] docs: refresh README and repository metadata --- .gitignore | 10 +++-- README.md | 80 +++++++++++++++++++++++++++++-------- uv.lock | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 20 deletions(-) create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index b795c38..b122609 100644 --- a/.gitignore +++ b/.gitignore @@ -98,7 +98,7 @@ ipython_config.py # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. -uv.lock +# uv.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. @@ -199,7 +199,7 @@ cython_debug/ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore # and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder -# .vscode/ +.vscode/ # Ruff stuff: .ruff_cache/ @@ -218,4 +218,8 @@ __marimo__/ # Test data config backups -start.sh \ No newline at end of file +start.sh + +.DS_Store +.codex +CLAUDE.md diff --git a/README.md b/README.md index 1ceb717..d2770b9 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,78 @@ -# GitHub backup script +# GitHub Backup -This container contains a script, `backup.py`, for backing up GitHub repositories. +This project builds a Docker image and Python CLI for backing up every GitHub repository visible to a personal access token. Repositories are stored as bare Git repositories under `{directory}/{owner}/{repo}`. -The script requires a GitHub token and a destination directory. It then uses the token to populate the destination directory with clones of all the repositories the token can access. +The current implementation is aimed at unattended runs on Unraid, but the backup engine is now packaged as a normal Python module as well. -It is possible to set it to run on a schedule, and repeated runs only update the already existing backups and add new repositories, if any. +## What It Does -## Installation +- Lists repositories available to the configured GitHub token. +- Optionally appends repositories from explicitly configured extra orgs. +- Optionally filters repositories by owner. +- Clones new repositories as bare repositories. +- Fetches updates for existing repositories. +- Runs on a schedule inside the container when started with `backup.sh`. -Installation can be completed via CA on Unraid. +## Performance And Robustness Changes -## Configuring +- Uses `per_page=100` when listing repositories from the GitHub API. +- Syncs repositories concurrently with a bounded worker pool. +- Avoids rewriting the persistent config file on container startup. +- Stops embedding the token in Git remote URLs. +- Removes the expensive recursive `chown` after every run. +- Logs Git fetch ref changes when a repo actually changed. +- Suppresses per-repo no-op logs and keeps output focused on changed repos, failures, and the final summary. -### Create a token +## Configuration -For authorization you need to create a new personal GitHub token. You can do this from [this](https://github.com/settings/tokens) page. +`config.json` supports: -![Step 1](https://raw.githubusercontent.com/lnxd/docker-github-backup/master/images/new-token-1.png) +```json +{ + "token": "github_token_here", + "directory": "/home/docker/backups", + "concurrency": 4, + "schedule_seconds": 86400, + "failure_delay_seconds": 300, + "extra_orgs": ["optional-extra-org"], + "owners": ["optional-owner-filter"] +} +``` -When you click the **Generate new token** button you enter the token creation screen. Here you should give the token a descriptive name and choose its *scopes*, which basically determine what the token is allowed to do. +Environment variables override config values: -![Step 2](https://raw.githubusercontent.com/lnxd/docker-github-backup/master/images/new-token-2.png) +- `TOKEN` or `GITHUB_TOKEN` +- `BACKUP_DIRECTORY` +- `CONCURRENCY` +- `SCHEDULE` +- `FAILURE_DELAY_SECONDS` +- `EXTRA_ORGS` +- `OWNERS` -To backup public and private repositories you need to select only the **repo** scope. If you have no need for private repositories just choose the **public_repo** scope. +`extra_orgs` adds repositories from named organizations on top of the normal `/user/repos` sync. This is useful when your token can access an org, but you want that org included explicitly without changing the default behavior for your own repos. -![Step 3](https://raw.githubusercontent.com/lnxd/docker-github-backup/master/images/new-token-3.png) +## Docker -After clicking the **Generate token** button you're presented with the generated token. Remember to store it now, as GitHub won't show it to you anymore! +Build the image: -## Final notes -If you notice any bugs, feel free to open an Issue or a pull request. For support with using this on Unraid, you can reach me best via the [support thread](https://forums.unraid.net/topic/104589-support-lnxd-phoenixminer-amd/) on the Unraid Community Forums. \ No newline at end of file +```bash +docker build -t github-backup:latest . +``` + +Run it: + +```bash +docker run --rm \ + --name github-backup \ + -e TOKEN=ghp_your_token_here \ + -e SCHEDULE=86400 \ + -v "$(pwd)/config:/home/docker/github-backup/config" \ + -v "$(pwd)/backups:/home/docker/backups" \ + github-backup:latest +``` + +## Local Usage + +```bash +python -m github_backup ./config.json +``` diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bcd2f06 --- /dev/null +++ b/uv.lock @@ -0,0 +1,113 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "github-backup" +source = { editable = "." } +dependencies = [ + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "requests", specifier = "~=2.33" }] + +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.15.10" }] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, + { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, + { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, + { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, + { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, + { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, + { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, + { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, + { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, + { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, + { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] From edfba3b7cce932184154b21c5575b161a1f742ce Mon Sep 17 00:00:00 2001 From: Luke Eltiste Date: Tue, 14 Apr 2026 17:46:49 -0700 Subject: [PATCH 3/3] linting: fix linting --- github_backup/config.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/github_backup/config.py b/github_backup/config.py index 07f06b4..b2bc5a0 100644 --- a/github_backup/config.py +++ b/github_backup/config.py @@ -80,10 +80,13 @@ def load_config(config_path: str | Path) -> AppConfig: raise ConfigError("Backup directory is required via config or BACKUP_DIRECTORY") owners = _read_name_list(os.environ.get("OWNERS") or payload.get("owners"), field_name="owners") - extra_orgs = _read_name_list( - os.environ.get("EXTRA_ORGS") or payload.get("extra_orgs"), - field_name="extra_orgs", - ) or frozenset() + extra_orgs = ( + _read_name_list( + os.environ.get("EXTRA_ORGS") or payload.get("extra_orgs"), + field_name="extra_orgs", + ) + or frozenset() + ) concurrency = _read_positive_int( os.environ.get("CONCURRENCY", payload.get("concurrency", DEFAULT_CONCURRENCY)),