You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
https://editorconfig.org/: "EditorConfig files use an INI format that is compatible with the format used by Python ConfigParser Library, but [ and ] are allowed in the section names."
A String name of the Ace Mode used for highlighting whenever a file is edited. This must match one of the filenames in https://gh.io/acemodes. Use "text" if a mode does not exist.
[build-system]
requires = ["hatchling==1.27.0"]
build-backend = "hatchling.build"
[project]
name = "glone"dynamic = ["version"]
description = "A CLI to back up all your GitHub repositories."readme = "README.md"requires-python = ">=3.10"license = "MIT"authors = [{ name = "João Palmeiro", email = "joaopalmeiro@proton.me" }]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: Information Technology",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
"Topic :: Utilities",
]
dependencies = [
"click>=8.1.0",
"httpx>=0.28.0",
"platformdirs>=4.0.0",
"pydantic>=2.11.0",
"trio>=0.29.0",
"gaveta==0.6.0",
]
[project.urls]
Issues = "https://github.com/joaopalmeiro/glone/issues"Source = "https://github.com/joaopalmeiro/glone"
[project.scripts]
glone = "glone.cli:main"
[tool.hatch.version]
path = "src/glone/__init__.py"
[tool.hatch.envs.default]
detached = falsedependencies = ["ruff==0.6.9", "mypy==1.12.0"]
[tool.hatch.envs.default.scripts]
lint = ["- ruff check .", "mypy"]
format = ["- ruff check --fix .", "ruff format ."]
[tool.hatch.build]
packages = ["src/glone"]
README.md file
# glone[](https://github.com/PyCQA/bandit)[](https://github.com/psf/black)
A Python CLI to backup all your GitHub repositories.
## Development-`poetry install`-`poetry shell`## Tech Stack-[Click](https://click.palletsprojects.com/) (for the interface)
### Packaging and Development-[Poetry](https://python-poetry.org/)-[Mypy](http://mypy-lang.org/)-[isort](https://pycqa.github.io/isort/)-[Black](https://github.com/psf/black)-[Flake8](https://flake8.pycqa.org/)-[flake8-bugbear](https://github.com/PyCQA/flake8-bugbear)-[flake8-comprehensions](https://github.com/adamchainz/flake8-comprehensions)-[pep8-naming](https://github.com/PyCQA/pep8-naming)-[flake8-builtins](https://github.com/gforcada/flake8-builtins)-[Bandit](https://bandit.readthedocs.io/)
This CLI was created with [Cookiecutter](https://github.com/audreyr/cookiecutter) and the [`joaopalmeiro/cookiecutter-templates/python-cli`](https://github.com/joaopalmeiro/cookiecutter-templates) project template.
## Notes-[Backup script](https://github.com/joaopalmeiro/scriptkit-playground/blob/main/google-zx/backup-gh.mjs).
-[ghapi documentation](https://ghapi.fast.ai/) ([API](https://ghapi.fast.ai/fullapi.html)).
- Commands:
-`glone joaopalmeiro`.
-`glone joaopalmeiro -o ~/Downloads`.
-`glone joaopalmeiro -o ~/Downloads -f tar`.
-`glone --help`.
-`glone --version`.
-[GitPython](https://github.com/gitpython-developers/GitPython) package.
-[GitHub CLI](https://cli.github.com/) (a.k.a. `gh`).
-[humanize](https://github.com/jmoiron/humanize/) package. `poetry add humanize`.
# glone[](https://github.com/pypa/hatch)[](https://github.com/astral-sh/ruff)[](https://pydantic.dev)
A CLI to back up all your [GitHub](https://github.com/) repositories.
-[Source code](https://github.com/joaopalmeiro/glone)-[PyPI package](https://pypi.org/project/glone/)-[Snyk Advisor](https://snyk.io/advisor/python/glone)## Usage### Via [pipx](https://github.com/pypa/pipx)```bash
pipx run glone --help
``````bash
pipx run glone
```## Development
Install [pyenv](https://github.com/pyenv/pyenv) (if necessary).
```bash
pyenv install && pyenv versions
``````bash
pip install hatch==1.14.0 && hatch --version
``````bash
hatch config set dirs.env.virtual .hatch
``````bash
hatch config show
``````bash
hatch env create
``````bash
hatch status
``````bash
hatch env show
``````bash
hatch dep show table
``````bash
hatch run pip list
``````bash
hatch run glone --help
``````bash
hatch run glone
``````bash
hatch run lint
``````bash
hatch run format
```## Deployment```bash
hatch version micro
``````bash
hatch version minor
``````bash
hatch version major
``````bash
hatch build --clean
``````bashecho"v$(hatch version)"| pbcopy
```- Commit and push changes.
- Create a tag on [GitHub Desktop](https://github.blog/2020-05-12-create-and-push-tags-in-the-latest-github-desktop-2-5-release/).
- Check [GitHub](https://github.com/joaopalmeiro/glone/tags).
```bash
hatch publish
```- Check [PyPI](https://pypi.org/project/glone/).
frompathlibimportPathimportclickimporthttpximporttriofromgaveta.filesimportensure_dirfromgaveta.timeimportISOFormat, now_isofromgloneimport__version__fromglone.cli.constantsimport (
ARCHIVE_FORMAT,
BASE_OUTPUT_FOLDER,
BASE_URL,
DEFAULT_ENV_VARIABLE,
REPOS_URL,
)
fromglone.cli.modelsimportRepo, Repos# ── Tuning knobs ──────────────────────────────────────────────────────────────MAX_CONCURRENT_DOWNLOADS=20CHUNK_SIZE=1024*1024# 1 MB — fewer async write() calls through trio's thread pool# ─────────────────────────────────────────────────────────────────────────────defsave_repos(repos: list[Repo], output_folder: Path) ->None:
output_repos=output_folder/"repos.json"withoutput_repos.open(mode="w", encoding="utf-8") asf:
f.write(Repos.dump_json(repos, indent=2).decode("utf-8"))
f.write("\n")
click.echo(f"{output_repos} ✓")
defgenerate_archive_endpoint(repo: Repo) ->str:
returnf"/repos/{repo.full_name}/{ARCHIVE_FORMAT}ball/{repo.default_branch}"asyncdefdownload_archive(
client: httpx.AsyncClient,
repo: Repo,
output_folder: Path,
limiter: trio.CapacityLimiter,
) ->None:
asyncwithlimiter:
url=generate_archive_endpoint(repo)
filename=f"{repo.name}-{repo.default_branch}.{ARCHIVE_FORMAT}"output_archive=output_folder/filename# stream="GET": bytes go straight to disk as they arrive instead of# buffering the entire archive in RAM before the first write.try:
asyncwithclient.stream("GET", url) asr:
r.raise_for_status()
asyncwithawaittrio.open_file(output_archive, mode="wb") asf:
asyncforchunkinr.aiter_bytes(chunk_size=CHUNK_SIZE):
awaitf.write(chunk)
click.echo(f"{output_archive} ✓")
exceptExceptionasexc:
# Catch per-task: one failed download must not cancel the nursery# and abort all other in-flight downloads.click.echo(f"{output_archive} ✗ ({exc})", err=True)
asyncdefrun(headers: httpx.Headers, output_folder: Path) ->list[Repo]:
all_repos: list[Repo] = []
limiter=trio.CapacityLimiter(MAX_CONCURRENT_DOWNLOADS)
asyncwith (
# One shared client for both repo listing and archive downloads:# • connection pool is reused — no repeated TLS handshakes# • http2=True multiplexes concurrent API calls over fewer TCP connections# (install httpx[http2] to enable: `pip install httpx[http2]`)httpx.AsyncClient(
base_url=BASE_URL,
headers=headers,
follow_redirects=True,
http2=True,
limits=httpx.Limits(
max_connections=MAX_CONCURRENT_DOWNLOADS+5,
max_keepalive_connections=MAX_CONCURRENT_DOWNLOADS,
keepalive_expiry=30,
),
) asclient,
trio.open_nursery() asnursery,
):
# Pipeline: kick off archive downloads for each page of repos immediately,# so downloads are already running while pagination is still in progress.# Original code waited for ALL repos before starting ANY download.next_url: httpx.URL|None=httpx.URL(
REPOS_URL, params={"visibility": "all", "affiliation": "owner", "per_page": 100}
)
whilenext_urlisnotNone:
r=awaitclient.get(next_url)
r.raise_for_status()
page=Repos.validate_python(r.json())
all_repos.extend(page)
forrepoinpage:
nursery.start_soon(download_archive, client, repo, output_folder, limiter)
next_url=httpx.URL(r.links["next"]["url"]) if"next"inr.linkselseNone# Nursery exit waits for all in-flight downloads to finish.returnall_repos@click.command()@click.option("-t","--token",type=str,metavar="VALUE",envvar=DEFAULT_ENV_VARIABLE,show_envvar=True,)@click.version_option(version=__version__)defmain(token: str) ->None:
"""A CLI to back up all your GitHub repositories."""output_folder=BASE_OUTPUT_FOLDER/now_iso(ISOFormat.BASIC)
ensure_dir(output_folder)
headers=httpx.Headers(
{
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
}
)
# Single trio.run() keeps repo listing and all downloads in one event loop.repos=trio.run(run, headers, output_folder)
save_repos(repos, output_folder)
click.echo(f"Number of repos: {len(repos)}")
click.echo(f"Output folder: {output_folder}")
click.echo("Done!")