#!/usr/bin/env -S uv -q run --script """ Upload files to a junk drawer I use this like a substitute for gist on codeberg. It's not a one-to-one replacement, but it does meet my needs. (embedding would be nice though) It grabs the credentials from a tea or forgego-cli configuration file, so set that up first! """ # SPDX-FileCopyrightText: 2025 Jeff Epler # # SPDX-License-Identifier: AGPL-3.0-or-later # /// script # requires-python = ">=3.9" # dependencies = [ # "requests", # "platformdirs", # "pyyaml", # "click", # ] # /// import binascii import hashlib import json import pathlib import random import string import sys import typing import urllib.parse import click import platformdirs import requests import yaml base36_chars = string.ascii_lowercase + string.digits def random_base36(n): return "".join(random.choice(base36_chars) for _ in range(n)) def get_loader(): try: return yaml.CLoader except AttributeError: return yaml.Loader def yaml_load(stream): return yaml.load(stream, Loader=get_loader()) def symlink_path(f: pathlib.Path) -> pathlib.Path: f = f.absolute() symlink_farm = platformdirs.user_cache_path("junk") symlink_farm.mkdir(parents=True, exist_ok=True) symlink_name = str(f).replace("%", "%%").replace("/", "%2f") return symlink_farm/symlink_name def find_path_from_file(f: pathlib.Path) -> str | None: p = symlink_path(f) if not p.is_symlink(): print(f"Did not find path for {f} via {p}") return None r = p.readlink() print(f"Stored path for {f} is {r} via {p}") return r def store_path_from_file(f: pathlib.Path, path: str) -> None: p = symlink_path(f) tmp_p = p.with_suffix("." + random_base36(8)) tmp_p.symlink_to(path) tmp_p.replace(p) print(f"Saved path for {f} at {p} -> {path}") default_login = 0 def nth(seq, n): for i, si in enumerate(seq): if i == n: return si raise IndexError def get_config(login): tea_config = platformdirs.user_config_path("tea") / "config.yml" if tea_config.exists(): with open(tea_config) as f: config = yaml_load(f) return config["logins"][login] fj_config = platformdirs.user_data_path("forgejo-cli") / "keys.json" if fj_config.exists(): config = json.loads(fj_config.read_text()) aliases = config["aliases"] hosts = config["hosts"] while login in aliases: login = aliases[login] if isinstance(login, int): host, data = nth(hosts.items(), login) else: host = login data = hosts[login] return {"url": f"https://{host}", "token": data["token"], "user": data["name"]} raise SystemExit("Could not find configuration for tea or forgejo_cli") def verbose(): import http.client as http_client import logging http_client.HTTPConnection.debuglevel = 1 # You must initialize logging, otherwise you'll not see debug output. logging.basicConfig() logging.getLogger().setLevel(logging.DEBUG) requests_log = logging.getLogger("requests.packages.urllib3") requests_log.setLevel(logging.DEBUG) requests_log.propagate = True # verbose() @click.command @click.option("--login", type=str, default=None) @click.option("--repo", type=str, default="junkdrawer") @click.option("--path", type=str, default=None) @click.option("--stdin-filename", "-f", type=str, default="stdin.txt") @click.option("--dry-run/--no-dry-run", "-n", type=bool, default=False) @click.argument("files", type=click.File("rb"), nargs=-1) def main( login, repo, path, stdin_filename, files: list[typing.BinaryIO], dry_run: bool ): if not files: files = [sys.stdin.buffer] if login is None: login = default_login if isinstance(login, str) and login[0] in string.digits: login = int(login) config = get_config(login) baseurl = config["url"] token = config["token"] user = config["user"] headers = { "Authorization": f"token {token}", "Accept": "application/json", } first = True if path is None: for f in files: f_path = pathlib.Path(f.name) path = find_path_from_file(f_path) if path is not None: break if path is None: path = random_base36(8) # Simply ignore birthday paradox for f in files: f_path = pathlib.Path(f.name) if not first: print() first = False if f.name == "": filename = stdin_filename else: filename = pathlib.Path(f.name).name filepath = f"{path}/{filename}" qfilepath = urllib.parse.quote(filepath, "") url = f"{baseurl}/api/v1/repos/{user}/{repo}/contents/{qfilepath}" content: bytes = f.read() sha = hashlib.new("sha1", content).hexdigest() content_base64 = binascii.b2a_base64(content).strip().decode("ascii") body = {"content": content_base64} # ugugugug contents method is "POST" to create, "PUT" to change. # Have to know if exists and the sha if so... response = requests.get(url, headers=headers) exists = response.status_code == 200 if exists: body["sha"] = response.json()["sha"] method = requests.put if exists else requests.post if dry_run: print( f"Would upload to {url}, {'replacing existing' if exists else 'creating new'} file" ) else: response = method(url, headers=headers, json=body) response.raise_for_status() j = response.json() html_url = j["content"]["html_url"] raw_url = j["content"]["download_url"] ref = j["commit"]["sha"][:12] html_ref_url = f"{baseurl}/{user}/{repo}/src/commit/{ref}/{filepath}" raw_ref_url = f"{baseurl}/{user}/{repo}/raw/commit/{ref}/{filepath}" print(f"Uploaded {filepath} at {ref}") print(f"HTML (HEAD): {html_url}") print(f"HTML (ref): {html_ref_url}") print(f"Raw (HEAD): {raw_url}") print(f"Raw (ref): {raw_ref_url}") print(f"Aether: [junk {filepath} [lang ...]]") if not dry_run: store_path_from_file(f_path, path) if __name__ == "__main__": main()