initial commit

This commit is contained in:
Skilly
2026-09-06 15:12:38 +02:00
parent 4caf096780
commit 0d77a07291
185 changed files with 15465 additions and 185 deletions

View File

@@ -0,0 +1,45 @@
"""What every link handler shares."""
import os
from archipelago_tester.pipeline.download.local_file import LocalFile
class LinkHandler:
"""One recognised shape of sheet link, and how to fetch from it.
parse() returning None means "not my shape", which is different from
"mine, and it failed": the caller only moves on to the next link in
the first case.
"""
def __init__(self, shapes):
self.shapes = shapes
def parse(self, link):
raise NotImplementedError
def download(self, parsed, context):
raise NotImplementedError
def file(self, context, filename):
return LocalFile(context.session, context.target_path(filename))
def collect(self, candidates, context, fetch, verify):
"""Download each candidate, dropping any that is another game."""
downloaded, kept, rejected = [], [], []
for candidate in candidates:
path, status = fetch(candidate)
if status in LocalFile.NEEDS_DOWNLOAD and verify:
mismatch = context.wrong_game(path)
if mismatch is not None:
os.remove(path)
rejected.append((candidate["name"], mismatch))
continue
downloaded.append((path, status))
kept.append(candidate)
return downloaded, kept, rejected
def rejected_detail(self, rejected, subject):
details = "; ".join(f"{name} -> {found}" for name, found in rejected)
return f"downloaded {subject} didn't match this game: {details}"

View File

@@ -0,0 +1,44 @@
"""A releases page on a self-hosted Gitea or Forgejo instance."""
from archipelago_tester.pipeline.download.handlers.base import LinkHandler
from archipelago_tester.pipeline.download.release_downloader import (
ReleaseDownloader,
)
class GiteaRelease(LinkHandler):
"""Codeberg and friends.
The release JSON is close enough to GitHub's that ReleaseDownloader
handles it unchanged, given headers that omit the GitHub token.
"""
def __init__(self, shapes, previous=None):
super().__init__(shapes)
self.previous = previous
def parse(self, link):
return self.shapes.gitea_releases(link)
def latest(self, context, host, owner, repository):
response = context.session.get(
f"https://{host}/api/v1/repos/{owner}/{repository}"
"/releases/latest",
headers=context.json_headers,
)
if response.status_code == 404:
return None
response.raise_for_status()
return response.json()
def download(self, parsed, context):
host, owner, repository = parsed
release = self.latest(context, host, owner, repository)
if release is None:
return None, "no release found on this host", None
return ReleaseDownloader(
context=context,
release=release,
headers=context.plain_headers,
previous_published_at=(self.previous or {}).get("published_at"),
).run()

View File

@@ -0,0 +1,58 @@
"""A link straight at an apworld committed in a GitHub repo."""
import os
import urllib.parse
import requests
from archipelago_tester.pipeline.build.archipelago import (
ArchipelagoBuild,
)
from archipelago_tester.pipeline.download.local_file import LocalFile
from archipelago_tester.pipeline.download.handlers.base import LinkHandler
class GitHubFile(LinkHandler):
"""A committed file, named exactly by the sheet.
No identity check: the link names one file, so there is no second
candidate a name comparison could choose between.
"""
def parse(self, link):
return self.shapes.github_file(link)
def download(self, parsed, context):
"""Fetch the file as it currently stands on that branch."""
owner, repository, branch, path = parsed
filename = os.path.basename(urllib.parse.unquote(path))
local = self.file(context, filename)
url = ("https://raw.githubusercontent.com/"
f"{owner}/{repository}/{branch}/{path}")
status = local.status(local.remote_size(url))
if status in LocalFile.NEEDS_DOWNLOAD:
local.download(url)
published = self.published_at(context, owner, repository, path, branch)
return [(local.path, status)], None, {"published_at": published}
def published_at(self, context, owner, repository, path, branch):
"""When this file was last committed, or None.
Best effort: a blob link has no release to read a date from, and
a download should not fail over display information.
"""
try:
response = context.session.get(
f"{ArchipelagoBuild.API_ROOT}/repos/{owner}/{repository}"
"/commits",
params={"path": path, "sha": branch, "per_page": 1},
)
response.raise_for_status()
commits = response.json()
except requests.RequestException:
return None
if not commits:
return None
commit = commits[0].get("commit", {})
return ((commit.get("committer") or {}).get("date")
or (commit.get("author") or {}).get("date"))

View File

@@ -0,0 +1,81 @@
"""A GitHub releases page, the sheet's usual link."""
from archipelago_tester.pipeline.download.handlers.base import LinkHandler
from archipelago_tester.pipeline.download.release_downloader import (
ReleaseDownloader,
)
from archipelago_tester.pipeline.download.release_identity import (
ReleaseIdentity,
)
from archipelago_tester.pipeline.download.release_picker import ReleasePicker
from archipelago_tester.pipeline.download.walk_back import WalkBack
class GitHubRelease(LinkHandler):
"""Resolve which release the link means, then fetch its apworld."""
def __init__(self, shapes, previous=None):
super().__init__(shapes)
self.previous = previous
def parse(self, link):
return self.shapes.releases(link)
def resolve(self, parsed, context):
"""The release this link resolves to, or None.
A hand-pinned tag prefix wins, then a link naming one exact
tag, then the search term. Runs every pass: it is the one call
that can report a new release, which is the only thing able to
change a previously failed answer.
"""
owner, repository, term, tag = parsed
picker = ReleasePicker(context, owner, repository, term)
overrides = context.config.get("release_overrides") or {}
prefix = overrides.get(context.game.name)
if prefix:
return picker.by_tag_prefix(prefix)
if tag:
return picker.by_tag(tag)
return picker.pick()
def fetch(self, parsed, context, release):
"""This release's apworld, walking back if it ships none.
Only that one cause is walked back from. "Ships another game's
apworld" must not be, since an older release of a multi-world
repo is precisely the wrong answer there.
"""
owner, repository = parsed[0], parsed[1]
downloader = ReleaseDownloader(
context=context,
release=release,
# What this row's date already says, so a re-upload of
# unchanged bytes keeps it instead of moving it forward.
previous_published_at=(self.previous or {}).get("published_at"),
)
downloaded, reason, info = downloader.run()
if (downloaded is not None or context.identifier is None
or downloader.cause != ReleaseDownloader.NO_APWORLD):
return downloaded, reason, info
fallback, reason = WalkBack(
context=context,
owner=owner,
repository=repository,
release=release,
).run(reason)
return fallback if fallback is not None else (None, reason, info)
def download(self, parsed, context):
release = self.resolve(parsed, context)
if release is None:
return None, "no release matched the search term in the link", None
identity = ReleaseIdentity(release)
repeat = identity.repeated(self.previous)
if repeat is not None:
return None, repeat, identity.failure(
{"published_at": None}, repeat)
downloaded, reason, info = self.fetch(parsed, context, release)
if downloaded is None:
return None, reason, identity.failure(info, reason)
return downloaded, reason, info

View File

@@ -0,0 +1,115 @@
"""A bare repository link, for a world never cut into a release."""
import os
import requests
from archipelago_tester.pipeline.build.archipelago import (
ArchipelagoBuild,
)
from archipelago_tester.pipeline.download.local_file import LocalFile
from archipelago_tester.core.model.apworld import ApworldFile
from archipelago_tester.pipeline.download.handlers.base import LinkHandler
class GitHubRepo(LinkHandler):
"""Browses the repo's own file tree for an apworld."""
def parse(self, link):
return self.shapes.github_repo(link)
def resolve_branch(self, context, owner, repository, branch):
"""The real default branch, so a version reads as a name.
"HEAD" works as a ref, but resolving it means the status page
shows a branch rather than the literal string.
"""
if branch != "HEAD":
return branch
try:
response = context.session.get(
f"{ArchipelagoBuild.API_ROOT}/repos/{owner}/{repository}")
response.raise_for_status()
return response.json().get("default_branch") or "HEAD"
except requests.RequestException:
return "HEAD"
def tree_entries(self, context, owner, repository, branch):
"""Every apworld in the tree, with its size.
One call: the trees API takes recursive=1 itself, and returns
each blob's size for free.
"""
try:
response = context.session.get(
f"{ArchipelagoBuild.API_ROOT}/repos/{owner}/{repository}"
f"/git/trees/{branch}",
params={"recursive": "1"},
)
response.raise_for_status()
except requests.RequestException:
return []
return [
(entry["path"], entry.get("size"))
for entry in response.json().get("tree", [])
if entry.get("type") == "blob"
and entry["path"].lower().endswith(ApworldFile.SUFFIX)
]
def candidates(self, entries):
return [
{"name": os.path.basename(path), "path": path, "size": size}
for path, size in entries
]
def download(self, parsed, context):
owner, repository, branch = parsed
branch = self.resolve_branch(context, owner, repository, branch)
entries = self.tree_entries(context, owner, repository, branch)
if not entries:
return None, ("repo link has no release and no .apworld file "
"found in it"), None
chosen = context.matcher.select(self.candidates(entries))
downloaded, kept, rejected = self.collect(
candidates=chosen,
context=context,
fetch=lambda candidate: self.fetch(
candidate, context, owner, repository, branch),
verify=len(chosen) > 1,
)
if not downloaded:
detail = self.rejected_detail(rejected, "file(s) from repo")
return None, detail, None
published = self.published_at(
context, owner, repository, kept[0]["path"], branch)
return downloaded, None, {"published_at": published}
def fetch(self, candidate, context, owner, repository, branch):
local = self.file(context, candidate["name"])
status = local.status(candidate.get("size"))
if status in LocalFile.NEEDS_DOWNLOAD:
local.download(
f"https://raw.githubusercontent.com/{owner}/{repository}"
f"/{branch}/{candidate['path']}")
return local.path, status
def published_at(self, context, owner, repository, path, branch):
"""When the first kept file was last committed.
Only the first: one API call per candidate otherwise.
"""
try:
response = context.session.get(
f"{ArchipelagoBuild.API_ROOT}/repos/{owner}/{repository}"
"/commits",
params={"path": path, "sha": branch, "per_page": 1},
)
response.raise_for_status()
commits = response.json()
except requests.RequestException:
return None
if not commits:
return None
commit = commits[0].get("commit", {})
return ((commit.get("committer") or {}).get("date")
or (commit.get("author") or {}).get("date"))

View File

@@ -0,0 +1,44 @@
"""A link straight at an apworld committed in a GitLab repo."""
import os
import urllib.parse
import requests
from archipelago_tester.pipeline.download.local_file import LocalFile
from archipelago_tester.pipeline.download.handlers.base import LinkHandler
class GitLabFile(LinkHandler):
"""A committed file on GitLab, named exactly by the sheet."""
def parse(self, link):
return self.shapes.gitlab_file(link)
def download(self, parsed, context):
project_path, branch, path = parsed
filename = os.path.basename(urllib.parse.unquote(path))
local = self.file(context, filename)
url = f"https://gitlab.com/{project_path}/-/raw/{branch}/{path}"
headers = context.json_headers
status = local.status(local.remote_size(url, headers=headers))
if status in LocalFile.NEEDS_DOWNLOAD:
local.download(url, headers=headers)
published = self.published_at(context, project_path, path, branch)
return [(local.path, status)], None, {"published_at": published}
def published_at(self, context, project_path, path, branch):
"""When this file was last committed, or None."""
encoded = urllib.parse.quote(project_path, safe="")
try:
response = context.session.get(
f"https://gitlab.com/api/v4/projects/{encoded}"
"/repository/commits",
params={"path": path, "ref_name": branch, "per_page": 1},
headers=context.json_headers,
)
response.raise_for_status()
commits = response.json()
except requests.RequestException:
return None
return commits[0].get("committed_date") if commits else None

View File

@@ -0,0 +1,74 @@
"""A GitLab releases page."""
import urllib.parse
from archipelago_tester.core.model.apworld import ApworldFile
from archipelago_tester.pipeline.download.handlers.base import LinkHandler
from archipelago_tester.pipeline.download.local_file import LocalFile
class GitLabRelease(LinkHandler):
"""The newest release of a GitLab project.
GitLab's assets are shaped nothing like GitHub's - a release-level
list of {name, url, direct_asset_url} with no per-asset timestamp -
so they are reshaped here rather than reusing the release path.
"""
def parse(self, link):
return self.shapes.gitlab_releases(link)
def latest(self, context, project_path):
"""GitLab's own newest-release shortcut, or None."""
encoded = urllib.parse.quote(project_path, safe="")
response = context.session.get(
f"https://gitlab.com/api/v4/projects/{encoded}"
"/releases/permalink/latest",
headers=context.json_headers,
)
if response.status_code == 404:
return None
response.raise_for_status()
return response.json()
def assets(self, release, context):
"""The release's apworld links, in GitHub's asset shape."""
links = (release.get("assets") or {}).get("links", [])
return [
{
"name": link["name"],
"browser_download_url": (link.get("direct_asset_url")
or link["url"]),
}
for link in links
if (link.get("name") or "").lower().endswith(ApworldFile.SUFFIX)
]
def fetch(self, asset, context):
local = self.file(context, asset["name"])
url = asset["browser_download_url"]
headers = context.plain_headers
status = local.status(local.remote_size(url, headers=headers))
if status in LocalFile.NEEDS_DOWNLOAD:
local.download(url, headers=headers)
return local.path, status
def download(self, parsed, context):
release = self.latest(context, parsed)
if release is None:
return None, "no GitLab release found for this project", None
info = {"published_at": (release.get("released_at")
or release.get("created_at"))}
assets = self.assets(release, context)
if not assets:
return None, "latest GitLab release has no .apworld asset", info
chosen = context.matcher.select(assets)
downloaded, _, rejected = self.collect(
candidates=chosen,
context=context,
fetch=lambda asset: self.fetch(asset, context),
verify=len(chosen) > 1,
)
if not downloaded:
return None, self.rejected_detail(rejected, "asset(s)"), info
return downloaded, None, info

View File

@@ -0,0 +1,48 @@
"""A plain URL ending in .apworld, on any host."""
import email.utils
import requests
from archipelago_tester.pipeline.download.local_file import LocalFile
from archipelago_tester.pipeline.download.handlers.base import LinkHandler
class PlainFile(LinkHandler):
"""Any other host. Tried last, so a forge handler always wins."""
def parse(self, link):
return self.shapes.plain_file(link)
def download(self, parsed, context):
url, filename = parsed
local = self.file(context, filename)
size, published = self.remote_facts(context, url)
status = local.status(size)
if status in LocalFile.NEEDS_DOWNLOAD:
local.download(url, headers=context.plain_headers)
return [(local.path, status)], None, {"published_at": published}
def remote_facts(self, context, url):
"""The size and date one HEAD request can offer.
A plain URL has no version to compare, so size is the whole
staleness check. Neither is required.
"""
try:
response = context.session.head(
url, headers=context.plain_headers, allow_redirects=True)
response.raise_for_status()
except requests.RequestException:
return None, None
length = response.headers.get("Content-Length")
size = int(length) if length and length.isdigit() else None
return size, self.modified_at(response.headers.get("Last-Modified"))
def modified_at(self, modified):
if not modified:
return None
try:
return email.utils.parsedate_to_datetime(modified).isoformat()
except (TypeError, ValueError):
return None