Skip to content

Instantly share code, notes, and snippets.

@AGulev
Last active May 27, 2026 16:09
Show Gist options
  • Select an option

  • Save AGulev/e41579eed51174756aaccb16f184ee94 to your computer and use it in GitHub Desktop.

Select an option

Save AGulev/e41579eed51174756aaccb16f184ee94 to your computer and use it in GitHub Desktop.
Check projects or repof for APIs removed in Defold 1.13.0
#!/usr/bin/env python3
# Change log:
# 2026-05-27: Added detection for follow-up API removals:
# - render.draw_debug2d(), removed in defold/defold#12462; use render.draw_debug3d().
# - "set_constant" message id strings, removed in defold/defold#12463; use go.set().
# - spine.set_constant(), removed in defold/extension-spine#278; use go.set().
"""Scan Defold projects for Lua APIs removed by defold/defold#12441 and follow-up PRs."""
from __future__ import annotations
import argparse
import bisect
import fnmatch
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable
from urllib.parse import quote, unquote, urlparse
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
DEFAULT_LUA_SUFFIXES = (
".lua",
".script",
".gui_script",
".render_script",
".editor_script",
)
DEFAULT_SKIP_DIR_PATTERNS = (
".git",
".hg",
".svn",
"__pycache__",
".gradle",
"node_modules",
"build",
"build-*",
"dist",
"out",
"target",
)
REMOVED_API_MIGRATIONS: tuple[tuple[str, str | None], ...] = (
("go.get_scale_vector", "go.get_scale()"),
("go.delete_all", "go.delete()"),
("camera.acquire_focus", None),
("camera.release_focus", None),
("label.get_text_metrics", "resource.get_text_metrics()"),
("model.play", "model.play_anim()"),
("model.set_constant", "go.set()"),
("physics.set_listener", "physics.set_event_listener()"),
("physics.ray_cast", "physics.raycast_async()"),
("spine.set_constant", "go.set()"),
("sprite.set_constant", "go.set()"),
("tilemap.set_constant", "go.set()"),
("gui.cancel_animation", "gui.cancel_animations()"),
("gui.get_text_metrics", "resource.get_text_metrics()"),
("gui.get_text_metrics_from_node", "resource.get_text_metrics()"),
("render.enable_render_target", "render.set_render_target()"),
("render.disable_render_target", "render.set_render_target(render.RENDER_TARGET_DEFAULT)"),
("render.draw_debug2d", "render.draw_debug3d()"),
("vmath.matrix4_from_quat", "vmath.matrix4_quat()"),
(
"sys.get_config",
"sys.get_config_string(), sys.get_config_int(), or sys.get_config_number()",
),
("render.STATE_DEPTH_TEST", "graphics.STATE_DEPTH_TEST"),
("render.STATE_STENCIL_TEST", "graphics.STATE_STENCIL_TEST"),
("render.STATE_ALPHA_TEST", "graphics.STATE_ALPHA_TEST"),
("render.STATE_BLEND", "graphics.STATE_BLEND"),
("render.STATE_CULL_FACE", "graphics.STATE_CULL_FACE"),
("render.STATE_POLYGON_OFFSET_FILL", "graphics.STATE_POLYGON_OFFSET_FILL"),
# The PR table and migrated project files contain both documented names and legacy aliases.
("render.FORMAT_LUMINANCE", "graphics.TEXTURE_FORMAT_LUMINANCE"),
("render.FORMAT_RGB", "graphics.TEXTURE_FORMAT_RGB"),
("render.RGB", "graphics.TEXTURE_FORMAT_RGB"),
("render.FORMAT_RGBA", "graphics.TEXTURE_FORMAT_RGBA"),
("render.FORMAT_DEPTH", "graphics.TEXTURE_FORMAT_DEPTH"),
("render.FORMAT_STENCIL", "graphics.TEXTURE_FORMAT_STENCIL"),
("render.FORMAT_RGB16F", "graphics.TEXTURE_FORMAT_RGB16F"),
("render.RGB16F", "graphics.TEXTURE_FORMAT_RGB16F"),
("render.FORMAT_RGB32F", "graphics.TEXTURE_FORMAT_RGB32F"),
("render.RGB32F", "graphics.TEXTURE_FORMAT_RGB32F"),
("render.FORMAT_RGBA16F", "graphics.TEXTURE_FORMAT_RGBA16F"),
("render.RGBA16F", "graphics.TEXTURE_FORMAT_RGBA16F"),
("render.FORMAT_RGBA32F", "graphics.TEXTURE_FORMAT_RGBA32F"),
("render.RGBA32F", "graphics.TEXTURE_FORMAT_RGBA32F"),
("render.FORMAT_R16F", "graphics.TEXTURE_FORMAT_R16F"),
("render.R16F", "graphics.TEXTURE_FORMAT_R16F"),
("render.FORMAT_RG16F", "graphics.TEXTURE_FORMAT_RG16F"),
("render.RG16F", "graphics.TEXTURE_FORMAT_RG16F"),
("render.FORMAT_R32F", "graphics.TEXTURE_FORMAT_R32F"),
("render.R32F", "graphics.TEXTURE_FORMAT_R32F"),
("render.FORMAT_RG32F", "graphics.TEXTURE_FORMAT_RG32F"),
("render.RG32F", "graphics.TEXTURE_FORMAT_RG32F"),
("render.FILTER_LINEAR", "graphics.TEXTURE_FILTER_LINEAR"),
("render.FILTER_NEAREST", "graphics.TEXTURE_FILTER_NEAREST"),
("render.WRAP_CLAMP", "graphics.TEXTURE_WRAP_CLAMP_TO_EDGE"),
("render.WRAP_CLAMP_TO_BORDER", "graphics.TEXTURE_WRAP_CLAMP_TO_BORDER"),
("render.WRAP_CLAMP_TO_EDGE", "graphics.TEXTURE_WRAP_CLAMP_TO_EDGE"),
("render.WRAP_MIRRORED_REPEAT", "graphics.TEXTURE_WRAP_MIRRORED_REPEAT"),
("render.WRAP_REPEAT", "graphics.TEXTURE_WRAP_REPEAT"),
("render.BLEND_ZERO", "graphics.BLEND_FACTOR_ZERO"),
("render.BLEND_ONE", "graphics.BLEND_FACTOR_ONE"),
("render.BLEND_SRC_COLOR", "graphics.BLEND_FACTOR_SRC_COLOR"),
("render.BLEND_ONE_MINUS_SRC_COLOR", "graphics.BLEND_FACTOR_ONE_MINUS_SRC_COLOR"),
("render.BLEND_DST_COLOR", "graphics.BLEND_FACTOR_DST_COLOR"),
("render.BLEND_ONE_MINUS_DST_COLOR", "graphics.BLEND_FACTOR_ONE_MINUS_DST_COLOR"),
("render.BLEND_SRC_ALPHA", "graphics.BLEND_FACTOR_SRC_ALPHA"),
("render.BLEND_FACTOR_SRC_ALPHA", "graphics.BLEND_FACTOR_SRC_ALPHA"),
("render.BLEND_ONE_MINUS_SRC_ALPHA", "graphics.BLEND_FACTOR_ONE_MINUS_SRC_ALPHA"),
("render.BLEND_FACTOR_ONE_MINUS_SRC_ALPHA", "graphics.BLEND_FACTOR_ONE_MINUS_SRC_ALPHA"),
("render.BLEND_DST_ALPHA", "graphics.BLEND_FACTOR_DST_ALPHA"),
("render.BLEND_ONE_MINUS_DST_ALPHA", "graphics.BLEND_FACTOR_ONE_MINUS_DST_ALPHA"),
("render.BLEND_SRC_ALPHA_SATURATE", "graphics.BLEND_FACTOR_SRC_ALPHA_SATURATE"),
("render.BLEND_CONSTANT_COLOR", "graphics.BLEND_FACTOR_CONSTANT_COLOR"),
("render.BLEND_ONE_MINUS_CONSTANT_COLOR", "graphics.BLEND_FACTOR_ONE_MINUS_CONSTANT_COLOR"),
("render.BLEND_CONSTANT_ALPHA", "graphics.BLEND_FACTOR_CONSTANT_ALPHA"),
("render.BLEND_ONE_MINUS_CONSTANT_ALPHA", "graphics.BLEND_FACTOR_ONE_MINUS_CONSTANT_ALPHA"),
("render.BLEND_EQUATION_ADD", "graphics.BLEND_EQUATION_ADD"),
("render.BLEND_EQUATION_SUBTRACT", "graphics.BLEND_EQUATION_SUBTRACT"),
("render.BLEND_EQUATION_REVERSE_SUBTRACT", "graphics.BLEND_EQUATION_REVERSE_SUBTRACT"),
("render.BLEND_EQUATION_MIN", "graphics.BLEND_EQUATION_MIN"),
("render.BLEND_EQUATION_MAX", "graphics.BLEND_EQUATION_MAX"),
("render.COMPARE_FUNC_NEVER", "graphics.COMPARE_FUNC_NEVER"),
("render.COMPARE_FUNC_LESS", "graphics.COMPARE_FUNC_LESS"),
("render.COMPARE_FUNC_LEQUAL", "graphics.COMPARE_FUNC_LEQUAL"),
("render.COMPARE_FUNC_GREATER", "graphics.COMPARE_FUNC_GREATER"),
("render.COMPARE_FUNC_GEQUAL", "graphics.COMPARE_FUNC_GEQUAL"),
("render.COMPARE_FUNC_EQUAL", "graphics.COMPARE_FUNC_EQUAL"),
("render.COMPARE_FUNC_NOTEQUAL", "graphics.COMPARE_FUNC_NOTEQUAL"),
("render.COMPARE_FUNC_ALWAYS", "graphics.COMPARE_FUNC_ALWAYS"),
("render.STENCIL_OP_KEEP", "graphics.STENCIL_OP_KEEP"),
("render.STENCIL_OP_ZERO", "graphics.STENCIL_OP_ZERO"),
("render.STENCIL_OP_REPLACE", "graphics.STENCIL_OP_REPLACE"),
("render.STENCIL_OP_INCR", "graphics.STENCIL_OP_INCR"),
("render.STENCIL_OP_INCR_WRAP", "graphics.STENCIL_OP_INCR_WRAP"),
("render.STENCIL_OP_DECR", "graphics.STENCIL_OP_DECR"),
("render.STENCIL_OP_DECR_WRAP", "graphics.STENCIL_OP_DECR_WRAP"),
("render.STENCIL_OP_INVERT", "graphics.STENCIL_OP_INVERT"),
("render.FACE_FRONT", "graphics.FACE_TYPE_FRONT"),
("render.FACE_BACK", "graphics.FACE_TYPE_BACK"),
("render.FACE_FRONT_AND_BACK", "graphics.FACE_TYPE_FRONT_AND_BACK"),
("render.BUFFER_COLOR_BIT", "graphics.BUFFER_TYPE_COLOR0_BIT"),
("render.BUFFER_COLOR0_BIT", "graphics.BUFFER_TYPE_COLOR0_BIT"),
("render.BUFFER_COLOR1_BIT", "graphics.BUFFER_TYPE_COLOR1_BIT"),
("render.BUFFER_COLOR2_BIT", "graphics.BUFFER_TYPE_COLOR2_BIT"),
("render.BUFFER_COLOR3_BIT", "graphics.BUFFER_TYPE_COLOR3_BIT"),
("render.BUFFER_DEPTH_BIT", "graphics.BUFFER_TYPE_DEPTH_BIT"),
("render.BUFFER_STENCIL_BIT", "graphics.BUFFER_TYPE_STENCIL_BIT"),
("render.BUFFER_TYPE_COLOR_BIT", "graphics.BUFFER_TYPE_COLOR0_BIT"),
("resource.TEXTURE_TYPE_2D", "graphics.TEXTURE_TYPE_2D"),
("resource.TEXTURE_TYPE_CUBE_MAP", "graphics.TEXTURE_TYPE_CUBE_MAP"),
("resource.TEXTURE_TYPE_2D_ARRAY", "graphics.TEXTURE_TYPE_2D_ARRAY"),
("resource.TEXTURE_TYPE_IMAGE_2D", "graphics.TEXTURE_TYPE_IMAGE_2D"),
("resource.BUFFER_TYPE_COLOR0_BIT", "graphics.BUFFER_TYPE_COLOR0_BIT"),
("resource.BUFFER_TYPE_COLOR1_BIT", "graphics.BUFFER_TYPE_COLOR1_BIT"),
("resource.BUFFER_TYPE_COLOR2_BIT", "graphics.BUFFER_TYPE_COLOR2_BIT"),
("resource.BUFFER_TYPE_COLOR3_BIT", "graphics.BUFFER_TYPE_COLOR3_BIT"),
("resource.BUFFER_TYPE_DEPTH_BIT", "graphics.BUFFER_TYPE_DEPTH_BIT"),
("resource.BUFFER_TYPE_STENCIL_BIT", "graphics.BUFFER_TYPE_STENCIL_BIT"),
("resource.TEXTURE_USAGE_FLAG_SAMPLE", "graphics.TEXTURE_USAGE_FLAG_SAMPLE"),
("resource.TEXTURE_USAGE_FLAG_MEMORYLESS", "graphics.TEXTURE_USAGE_FLAG_MEMORYLESS"),
("resource.TEXTURE_USAGE_FLAG_STORAGE", "graphics.TEXTURE_USAGE_FLAG_STORAGE"),
("resource.TEXTURE_FORMAT_LUMINANCE", "graphics.TEXTURE_FORMAT_LUMINANCE"),
("resource.TEXTURE_FORMAT_RGB", "graphics.TEXTURE_FORMAT_RGB"),
("resource.TEXTURE_FORMAT_RGBA", "graphics.TEXTURE_FORMAT_RGBA"),
("resource.TEXTURE_FORMAT_DEPTH", "graphics.TEXTURE_FORMAT_DEPTH"),
("resource.TEXTURE_FORMAT_STENCIL", "graphics.TEXTURE_FORMAT_STENCIL"),
("resource.TEXTURE_FORMAT_RGB_PVRTC_2BPPV1", "graphics.TEXTURE_FORMAT_RGB_PVRTC_2BPPV1"),
("resource.TEXTURE_FORMAT_RGB_PVRTC_4BPPV1", "graphics.TEXTURE_FORMAT_RGB_PVRTC_4BPPV1"),
("resource.TEXTURE_FORMAT_RGBA_PVRTC_2BPPV1", "graphics.TEXTURE_FORMAT_RGBA_PVRTC_2BPPV1"),
("resource.TEXTURE_FORMAT_RGBA_PVRTC_4BPPV1", "graphics.TEXTURE_FORMAT_RGBA_PVRTC_4BPPV1"),
("resource.TEXTURE_FORMAT_RGB_ETC1", "graphics.TEXTURE_FORMAT_RGB_ETC1"),
("resource.TEXTURE_FORMAT_RGBA_ETC2", "graphics.TEXTURE_FORMAT_RGBA_ETC2"),
("resource.TEXTURE_FORMAT_RGBA_ASTC_4X4", "graphics.TEXTURE_FORMAT_RGBA_ASTC_4X4"),
("resource.TEXTURE_FORMAT_RGBA_ASTC_4x4", "graphics.TEXTURE_FORMAT_RGBA_ASTC_4X4"),
("resource.TEXTURE_FORMAT_RGB_BC1", "graphics.TEXTURE_FORMAT_RGB_BC1"),
("resource.TEXTURE_FORMAT_RGBA_BC3", "graphics.TEXTURE_FORMAT_RGBA_BC3"),
("resource.TEXTURE_FORMAT_R_BC4", "graphics.TEXTURE_FORMAT_R_BC4"),
("resource.TEXTURE_FORMAT_RG_BC5", "graphics.TEXTURE_FORMAT_RG_BC5"),
("resource.TEXTURE_FORMAT_RGBA_BC7", "graphics.TEXTURE_FORMAT_RGBA_BC7"),
("resource.TEXTURE_FORMAT_RGB16F", "graphics.TEXTURE_FORMAT_RGB16F"),
("resource.TEXTURE_FORMAT_RGB32F", "graphics.TEXTURE_FORMAT_RGB32F"),
("resource.TEXTURE_FORMAT_RGBA16F", "graphics.TEXTURE_FORMAT_RGBA16F"),
("resource.TEXTURE_FORMAT_RGBA32F", "graphics.TEXTURE_FORMAT_RGBA32F"),
("resource.TEXTURE_FORMAT_R16F", "graphics.TEXTURE_FORMAT_R16F"),
("resource.TEXTURE_FORMAT_RG16F", "graphics.TEXTURE_FORMAT_RG16F"),
("resource.TEXTURE_FORMAT_R32F", "graphics.TEXTURE_FORMAT_R32F"),
("resource.TEXTURE_FORMAT_RG32F", "graphics.TEXTURE_FORMAT_RG32F"),
("resource.COMPRESSION_TYPE_DEFAULT", "graphics.COMPRESSION_TYPE_DEFAULT"),
("resource.COMPRESSION_TYPE_BASIS_UASTC", "graphics.COMPRESSION_TYPE_BASIS_UASTC"),
)
REMOVED_MESSAGE_MIGRATIONS: tuple[tuple[str, str | None], ...] = (
('"set_constant" message id string', "go.set()"),
)
SET_CONSTANT_MESSAGE_API = REMOVED_MESSAGE_MIGRATIONS[0][0]
API_REPLACEMENTS = dict(REMOVED_API_MIGRATIONS + REMOVED_MESSAGE_MIGRATIONS)
REMOVED_DOTTED_APIS = tuple(api for api, _ in REMOVED_API_MIGRATIONS)
REMOVED_APIS = REMOVED_DOTTED_APIS + tuple(api for api, _ in REMOVED_MESSAGE_MIGRATIONS)
IDENTIFIER_CHARS = r"A-Za-z0-9_"
DOCS_BASE_URL = "https://defold.com/ref/alpha"
API_REF_RE = re.compile(r"\b[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*\b")
SET_CONSTANT_MESSAGE_RE = re.compile(
r"(['\"])set_constant\1"
)
CUSTOM_DOC_LINKS: dict[str, tuple[tuple[str, str], ...]] = {
"camera.acquire_focus": (
("Using the camera", "https://defold.com/manuals/camera/index.html#using-the-camera"),
),
"camera.release_focus": (
("Using the camera", "https://defold.com/manuals/camera/index.html#using-the-camera"),
),
"gui.get_text_metrics": (
(
"gui.get_font_resource",
"https://defold.com/ref/gui-lua/index.html#gui.get_font_resource:font_name",
),
),
"gui.get_text_metrics_from_node": (
(
"gui.get_font_resource",
"https://defold.com/ref/gui-lua/index.html#gui.get_font_resource:font_name",
),
),
}
URL_RE = re.compile(
r"(?:https?://|ssh://|git@|file://|github\.com/)[^\s)>\]]+|"
r"(?<![\w.-])[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?(?![\w.-])"
)
@dataclass(frozen=True)
class Match:
path: str
line_number: int
api: str
@dataclass
class RepoReport:
source: str
label: str
matches: list[Match]
error: str | None = None
class HelpFormatter(argparse.RawDescriptionHelpFormatter):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, max_help_position=28, width=100, **kwargs)
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Scan a local Defold project folder, clone a Git repository, clone "
"repositories listed in a text file, or clone all repositories in a "
"GitHub organization or user account, and report Lua uses of APIs removed by "
"defold/defold#12441 and follow-up PRs."
),
formatter_class=HelpFormatter,
epilog="""examples:
%(prog)s --folder /path/to/local/project
%(prog)s --repo https://github.com/defold/tutorial-side-scroller
%(prog)s --repo defold/tutorial-side-scroller
%(prog)s --org defold
%(prog)s --org AGulev
%(prog)s --repos repos.txt
%(prog)s --folder /path/to/local/project --report reports
%(prog)s --list-apis
repo list files may contain plain URLs, owner/repo names, local paths, or Markdown links.
Git is only required when cloning repositories instead of scanning local folders.
Organization/account scans use the GitHub REST API. Set GITHUB_TOKEN for private organization
repositories, private repositories owned by the authenticated user, or higher rate limits.""",
)
source_group = parser.add_mutually_exclusive_group()
source_group.add_argument(
"--folder",
metavar="PATH",
help="Local Defold project folder to scan.",
)
source_group.add_argument(
"--repo",
metavar="URL_OR_OWNER_REPO",
help="Git repository URL or GitHub owner/repo name to clone and scan.",
)
source_group.add_argument(
"--repos",
metavar="PATH",
help="Text file containing repository URLs/names or local paths to scan.",
)
source_group.add_argument(
"--org",
metavar="GITHUB_OWNER",
help="GitHub organization or user account whose repositories should be cloned and scanned.",
)
parser.add_argument(
"--extensions",
default=",".join(DEFAULT_LUA_SUFFIXES),
help=(
"Comma-separated Lua file suffixes to scan "
f"(default: {', '.join(DEFAULT_LUA_SUFFIXES)})."
),
)
parser.add_argument(
"--keep-temp",
action="store_true",
help="Keep cloned repositories in the temporary directory after the scan.",
)
parser.add_argument(
"--skip-dir",
action="append",
default=list(DEFAULT_SKIP_DIR_PATTERNS),
metavar="PATTERN",
help=(
"Directory name pattern to skip while scanning. Can be passed multiple "
f"times (defaults: {', '.join(DEFAULT_SKIP_DIR_PATTERNS)})."
),
)
parser.add_argument(
"--fail-on-match",
action="store_true",
help="Exit with status 1 when removed API usages are found.",
)
parser.add_argument(
"--report",
nargs="?",
const="removed-lua-api-reports",
metavar="DIR",
help=(
"Write one Markdown report per scanned repository/folder to DIR. "
"If DIR is omitted, uses removed-lua-api-reports."
),
)
parser.add_argument(
"--list-apis",
action="store_true",
help="Print the removed API names, suggested replacements, and docs links, then exit.",
)
return parser
def parse_args() -> argparse.Namespace:
return build_arg_parser().parse_args()
def compile_patterns(apis: Iterable[str]) -> list[tuple[str, re.Pattern[str]]]:
patterns = []
for api in apis:
module, name = api.split(".", 1)
pattern = re.compile(
rf"(?<![{IDENTIFIER_CHARS}.])"
rf"{re.escape(module)}\s*\.\s*{re.escape(name)}"
rf"(?![{IDENTIFIER_CHARS}])"
)
patterns.append((api, pattern))
return patterns
def extract_repo_sources(target: str) -> list[str]:
target_path = Path(target).expanduser()
if target_path.is_file():
sources: list[str] = []
for raw_line in target_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
local_dir = source_to_local_dir(line)
if local_dir is not None:
sources.append(str(local_dir))
continue
match = URL_RE.search(line)
if match:
sources.append(match.group(0).rstrip(".,;"))
else:
sources.append(line)
return sources
return [target]
def github_api_headers() -> dict[str, str]:
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "defold-removed-lua-api-checker",
}
token = github_api_token()
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def github_api_token() -> str | None:
return os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
def log_progress(message: str) -> None:
print(message, file=sys.stderr, flush=True)
class GitHubAPIRequestError(RuntimeError):
def __init__(
self,
owner_kind: str,
owner: str,
status_code: int,
reason: str,
detail: str,
) -> None:
super().__init__(
f"GitHub API request for {owner_kind} `{owner}` failed: "
f"HTTP {status_code} {reason}. {detail}"
)
self.status_code = status_code
def fetch_github_repo_sources(
owner: str,
owner_kind: str,
url_for_page: Callable[[int], str],
) -> list[str]:
sources: list[str] = []
page = 1
while True:
log_progress(f"Fetching GitHub {owner_kind} `{owner}` repositories, page {page}...")
url = url_for_page(page)
request = Request(url, headers=github_api_headers())
try:
with urlopen(request, timeout=30) as response:
repos = json.load(response)
except HTTPError as error:
detail = error.read().decode("utf-8", errors="replace").strip()
raise GitHubAPIRequestError(
owner_kind, owner, error.code, error.reason, detail
) from error
except URLError as error:
raise RuntimeError(
f"GitHub API request for {owner_kind} `{owner}` failed: {error.reason}"
) from error
if not isinstance(repos, list):
raise RuntimeError(f"GitHub API returned an unexpected response for `{owner}`.")
if not repos:
break
for repo in repos:
if not isinstance(repo, dict):
continue
clone_url = repo.get("clone_url") or repo.get("html_url")
if isinstance(clone_url, str):
sources.append(clone_url)
if len(repos) < 100:
break
page += 1
log_progress(f"Found {len(sources)} GitHub repositories for {owner_kind} `{owner}`.")
return sources
def fetch_authenticated_github_login() -> str | None:
if github_api_token() is None:
return None
request = Request("https://api.github.com/user", headers=github_api_headers())
try:
with urlopen(request, timeout=30) as response:
profile = json.load(response)
except HTTPError as error:
detail = error.read().decode("utf-8", errors="replace").strip()
raise GitHubAPIRequestError(
"authenticated user", "token", error.code, error.reason, detail
) from error
except URLError as error:
raise RuntimeError(
f"GitHub API request for authenticated user failed: {error.reason}"
) from error
if isinstance(profile, dict):
login = profile.get("login")
if isinstance(login, str):
return login
return None
def fetch_user_repo_sources(user: str) -> list[str]:
authenticated_login = fetch_authenticated_github_login()
if authenticated_login is not None and authenticated_login.lower() == user.lower():
log_progress(
f"Authenticated as `{authenticated_login}`; fetching public and private owned repositories."
)
return fetch_github_repo_sources(
user,
"authenticated user",
lambda page: (
"https://api.github.com/user/repos"
f"?affiliation=owner&visibility=all&per_page=100&page={page}"
),
)
return fetch_github_repo_sources(
user,
"user",
lambda page: (
f"https://api.github.com/users/{quote(user, safe='')}/repos"
f"?type=all&per_page=100&page={page}"
),
)
def fetch_org_repo_sources(org: str) -> list[str]:
owner = org.strip().removeprefix("@")
try:
return fetch_github_repo_sources(
owner,
"organization",
lambda page: (
f"https://api.github.com/orgs/{quote(owner, safe='')}/repos"
f"?type=all&per_page=100&page={page}"
),
)
except GitHubAPIRequestError as error:
if error.status_code != 404:
raise
log_progress(f"GitHub organization `{owner}` was not found; trying user account `{owner}`...")
return fetch_user_repo_sources(owner)
def resolve_sources(args: argparse.Namespace) -> list[str]:
if args.folder:
local_dir = source_to_local_dir(args.folder)
if local_dir is None:
raise ValueError(f"Local folder does not exist: {args.folder}")
return [str(local_dir)]
if args.repo:
return [args.repo]
if args.repos:
repos_file = Path(args.repos).expanduser()
if not repos_file.is_file():
raise ValueError(f"Repository list file does not exist: {args.repos}")
return extract_repo_sources(str(repos_file))
if args.org:
return fetch_org_repo_sources(args.org)
return []
def normalize_repo_source(source: str) -> str:
source = source.strip()
if source.startswith("git@") or source.startswith("ssh://") or source.startswith("file://"):
return source
if source.startswith("github.com/"):
source = "https://" + source
parsed = urlparse(source)
if parsed.netloc.lower() == "github.com":
parts = [part for part in parsed.path.split("/") if part]
if len(parts) >= 2:
repo = parts[1]
if repo.endswith(".git"):
repo = repo[:-4]
return f"https://github.com/{parts[0]}/{repo}.git"
if re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?", source):
owner, repo = source.split("/", 1)
if repo.endswith(".git"):
repo = repo[:-4]
return f"https://github.com/{owner}/{repo}.git"
return source
def source_to_local_dir(source: str) -> Path | None:
local_path = Path(source).expanduser()
if local_path.is_dir():
return local_path
parsed = urlparse(source)
if parsed.scheme == "file":
file_path = Path(unquote(parsed.path)).expanduser()
if file_path.is_dir():
return file_path
return None
def repo_label(source: str) -> str:
parsed = urlparse(source)
if parsed.netloc.lower() == "github.com":
parts = [part for part in parsed.path.split("/") if part]
if len(parts) >= 2:
return f"{parts[0]}/{parts[1].removesuffix('.git')}"
if source.startswith("git@github.com:"):
repo = source.split(":", 1)[1].removesuffix(".git")
return repo
return source.rstrip("/").removesuffix(".git").split("/")[-1]
def safe_dir_name(label: str, index: int) -> str:
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", label).strip("._")
return f"{index:03d}_{safe or 'repo'}"
def clone_repo(source: str, destination: Path) -> None:
if shutil.which("git") is None:
raise MissingDependencyError("git")
subprocess.run(
["git", "clone", "--quiet", "--depth", "1", normalize_repo_source(source), str(destination)],
check=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
class MissingDependencyError(Exception):
def __init__(self, command: str) -> None:
super().__init__(command)
self.command = command
def install_hint(command: str) -> str:
if command == "git":
if sys.platform == "darwin":
return (
"Install Git with Xcode Command Line Tools (`xcode-select --install`) "
"or Homebrew (`brew install git`)."
)
if sys.platform.startswith("linux"):
return "Install Git with your package manager, for example `sudo apt install git`."
return "Install Git from https://git-scm.com/downloads and make sure it is on PATH."
return f"Install `{command}` and make sure it is on PATH."
def needs_git(source: str) -> bool:
return source_to_local_dir(source) is None
def find_missing_dependencies(sources: Iterable[str]) -> list[str]:
missing = []
if any(needs_git(source) for source in sources) and shutil.which("git") is None:
missing.append("git")
return missing
def sanitize_lua(text: str) -> str:
"""Replace Lua strings and comments with spaces while preserving line numbers."""
out: list[str] = []
i = 0
length = len(text)
while i < length:
char = text[i]
next_char = text[i + 1] if i + 1 < length else ""
if char == "-" and next_char == "-":
long_end = long_bracket_end(text, i + 2)
if long_end is not None:
replacement, i = blank_preserving_newlines(text, i, long_end)
out.append(replacement)
continue
line_end = text.find("\n", i)
if line_end == -1:
out.append(" " * (length - i))
break
out.append(" " * (line_end - i))
out.append("\n")
i = line_end + 1
continue
if char in ("'", '"'):
replacement, i = blank_quoted_string(text, i, char)
out.append(replacement)
continue
if char == "[":
long_end = long_bracket_end(text, i)
if long_end is not None:
replacement, i = blank_preserving_newlines(text, i, long_end)
out.append(replacement)
continue
out.append(char)
i += 1
return "".join(out)
def sanitize_lua_comments(text: str) -> str:
"""Replace Lua comments and long strings while preserving short string literals."""
out: list[str] = []
i = 0
length = len(text)
while i < length:
char = text[i]
next_char = text[i + 1] if i + 1 < length else ""
if char == "-" and next_char == "-":
long_end = long_bracket_end(text, i + 2)
if long_end is not None:
replacement, i = blank_preserving_newlines(text, i, long_end)
out.append(replacement)
continue
line_end = text.find("\n", i)
if line_end == -1:
out.append(" " * (length - i))
break
out.append(" " * (line_end - i))
out.append("\n")
i = line_end + 1
continue
if char in ("'", '"'):
start = i
i += 1
while i < length:
current = text[i]
i += 1
if current == "\\" and i < length:
i += 1
continue
if current == char:
break
out.append(text[start:i])
continue
if char == "[":
long_end = long_bracket_end(text, i)
if long_end is not None:
replacement, i = blank_preserving_newlines(text, i, long_end)
out.append(replacement)
continue
out.append(char)
i += 1
return "".join(out)
def long_bracket_end(text: str, start: int) -> int | None:
match = re.match(r"\[(=*)\[", text[start:])
if not match:
return None
equals = match.group(1)
content_start = start + len(match.group(0))
closing = "]" + equals + "]"
end = text.find(closing, content_start)
if end == -1:
return len(text)
return end + len(closing)
def blank_preserving_newlines(text: str, start: int, end: int) -> tuple[str, int]:
return ("".join("\n" if char == "\n" else " " for char in text[start:end]), end)
def blank_quoted_string(text: str, start: int, quote: str) -> tuple[str, int]:
out = [" "]
i = start + 1
while i < len(text):
char = text[i]
if char == "\n":
out.append("\n")
i += 1
continue
out.append(" ")
if char == "\\":
i += 1
if i < len(text):
out.append("\n" if text[i] == "\n" else " ")
i += 1
continue
i += 1
if char == quote:
break
return ("".join(out), i)
def scan_file(path: Path, root: Path, patterns: list[tuple[str, re.Pattern[str]]]) -> list[Match]:
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
text = path.read_text(encoding="utf-8", errors="ignore")
sanitized = sanitize_lua(text)
line_starts = [0]
for index, char in enumerate(sanitized):
if char == "\n":
line_starts.append(index + 1)
matches: set[Match] = set()
rel_path = path.relative_to(root).as_posix()
for api, pattern in patterns:
for match in pattern.finditer(sanitized):
line_number = bisect.bisect_right(line_starts, match.start())
matches.add(Match(rel_path, line_number, api))
matches.update(scan_removed_messages(text, line_starts, rel_path))
return sorted(matches, key=lambda item: (item.path, item.line_number, item.api))
def scan_removed_messages(
text: str,
line_starts: list[int],
rel_path: str,
) -> set[Match]:
matches: set[Match] = set()
comment_sanitized = sanitize_lua_comments(text)
for match in SET_CONSTANT_MESSAGE_RE.finditer(comment_sanitized):
line_number = bisect.bisect_right(line_starts, match.start())
matches.add(Match(rel_path, line_number, SET_CONSTANT_MESSAGE_API))
return matches
def git_scan_files(root: Path, suffixes: tuple[str, ...]) -> list[Path] | None:
if shutil.which("git") is None:
return None
result = subprocess.run(
["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
text=False,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
if result.returncode != 0:
return None
files = []
for raw_path in result.stdout.split(b"\0"):
if not raw_path:
continue
rel_path = Path(os.fsdecode(raw_path))
if rel_path.name.endswith(suffixes):
files.append(root / rel_path)
return files
def walk_scan_files(
root: Path,
suffixes: tuple[str, ...],
skip_dir_patterns: tuple[str, ...],
) -> Iterable[Path]:
for current_root, dirnames, filenames in os.walk(root):
dirnames[:] = [
dirname
for dirname in dirnames
if not any(fnmatch.fnmatch(dirname, pattern) for pattern in skip_dir_patterns)
]
current_path = Path(current_root)
for filename in filenames:
if filename.endswith(suffixes):
yield current_path / filename
def scan_repo(
root: Path,
suffixes: tuple[str, ...],
patterns: list[tuple[str, re.Pattern[str]]],
skip_dir_patterns: tuple[str, ...],
) -> list[Match]:
matches: list[Match] = []
files = git_scan_files(root, suffixes)
if files is None:
files = list(walk_scan_files(root, suffixes, skip_dir_patterns))
for path in files:
if path.is_file():
matches.extend(scan_file(path, root, patterns))
return sorted(matches, key=lambda item: (item.path, item.line_number, item.api))
def scan_sources(
sources: list[str],
suffixes: tuple[str, ...],
patterns: list[tuple[str, re.Pattern[str]]],
skip_dir_patterns: tuple[str, ...],
keep_temp: bool,
) -> list[RepoReport]:
temp_dir = Path(tempfile.mkdtemp(prefix="defold-removed-api-scan-"))
reports: list[RepoReport] = []
total = len(sources)
log_progress(f"Using temporary clone directory: {temp_dir}")
try:
for index, source in enumerate(sources, start=1):
label = repo_label(source)
local_path = source_to_local_dir(source)
prefix = f"[{index}/{total}] {label}"
try:
if local_path is not None:
root = local_path
log_progress(f"{prefix}: using local folder {root}")
else:
root = temp_dir / safe_dir_name(label, index)
log_progress(f"{prefix}: cloning {normalize_repo_source(source)}...")
clone_repo(source, root)
log_progress(f"{prefix}: clone complete.")
log_progress(f"{prefix}: analyzing Lua files...")
matches = scan_repo(root, suffixes, patterns, skip_dir_patterns)
log_progress(
f"{prefix}: analysis complete, found {len(matches)} removed API usage"
f"{'' if len(matches) == 1 else 's'}."
)
reports.append(RepoReport(source, label, matches))
except subprocess.CalledProcessError as error:
stderr = error.stderr.strip() if error.stderr else str(error)
log_progress(f"{prefix}: clone failed.")
reports.append(RepoReport(source, label, [], f"clone failed: {stderr}"))
except MissingDependencyError as error:
log_progress(f"{prefix}: missing dependency `{error.command}`.")
reports.append(
RepoReport(
source,
label,
[],
f"missing dependency: `{error.command}`. {install_hint(error.command)}",
)
)
except OSError as error:
log_progress(f"{prefix}: failed with error: {error}")
reports.append(RepoReport(source, label, [], str(error)))
finally:
if keep_temp:
log_progress(f"Temporary repositories kept in {temp_dir}")
else:
log_progress(f"Removing temporary clone directory: {temp_dir}")
shutil.rmtree(temp_dir)
return reports
def replacement_hint(api: str) -> str:
replacement = API_REPLACEMENTS[api]
if replacement is None:
return "no direct replacement"
return f"use {replacement} instead"
def api_docs_url(api: str) -> str:
namespace = api.split(".", 1)[0]
return f"{DOCS_BASE_URL}/{namespace}/#{api}"
def api_module_docs_url(api: str) -> str:
namespace = api.split(".", 1)[0]
return f"{DOCS_BASE_URL}/{namespace}/"
def replacement_doc_apis(api: str) -> tuple[str, ...]:
replacement = API_REPLACEMENTS[api]
if replacement is None:
return ()
refs = []
seen = set()
for match in API_REF_RE.finditer(replacement):
ref = match.group(0)
if ref not in seen:
refs.append(ref)
seen.add(ref)
return tuple(refs)
def docs_urls(api: str) -> str:
custom_links = CUSTOM_DOC_LINKS.get(api)
if custom_links is not None:
return ", ".join(url for _, url in custom_links)
refs = replacement_doc_apis(api)
if refs:
return ", ".join(api_docs_url(ref) for ref in refs)
return api_module_docs_url(api)
def docs_hint(api: str) -> str:
return f"docs: {docs_urls(api)}"
def list_api_entry(api: str) -> str:
replacement = API_REPLACEMENTS[api]
replacement_text = "no direct replacement" if replacement is None else replacement
return f"{api} -> {replacement_text} : {docs_urls(api)}"
def markdown_text(text: str) -> str:
return text.replace("\\", "\\\\").replace("|", "\\|").replace("\n", " ")
def markdown_code(text: str) -> str:
return "`" + markdown_text(text).replace("`", "\\`") + "`"
def markdown_doc_links(api: str) -> str:
custom_links = CUSTOM_DOC_LINKS.get(api)
if custom_links is not None:
return ", ".join(f"[{markdown_text(label)}]({url})" for label, url in custom_links)
refs = replacement_doc_apis(api)
if refs:
return ", ".join(f"[{markdown_code(ref)}]({api_docs_url(ref)})" for ref in refs)
namespace = api.split(".", 1)[0]
return f"[{markdown_text(namespace)} API]({api_module_docs_url(api)})"
def report_file_name(label: str, used_names: set[str]) -> str:
stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", label).strip("._") or "report"
name = f"{stem}.md"
suffix = 2
while name in used_names:
name = f"{stem}-{suffix}.md"
suffix += 1
used_names.add(name)
return name
def markdown_report(report: RepoReport) -> str:
lines = [
f"# Removed Lua API Report: {markdown_text(report.label)}",
"",
f"Source: {markdown_code(report.source)}",
"",
]
if report.error:
lines.extend(("## Error", "", markdown_text(report.error), ""))
return "\n".join(lines)
if not report.matches:
lines.extend(("No removed APIs found.", ""))
return "\n".join(lines)
lines.extend(
(
f"Found {len(report.matches)} removed API usage"
f"{'' if len(report.matches) == 1 else 's'}.",
"",
"| File | Line | Removed API | Suggested replacement | API docs |",
"| --- | ---: | --- | --- | --- |",
)
)
for match in report.matches:
replacement = API_REPLACEMENTS[match.api]
suggestion = "No direct replacement" if replacement is None else f"Use {markdown_code(replacement)}"
lines.append(
f"| {markdown_code(match.path)} | {match.line_number} | "
f"{markdown_code(match.api)} | {suggestion} | {markdown_doc_links(match.api)} |"
)
lines.append("")
return "\n".join(lines)
def write_markdown_reports(reports: list[RepoReport], output_dir: Path) -> list[Path]:
output_dir.mkdir(parents=True, exist_ok=True)
used_names: set[str] = set()
paths = []
log_progress(f"Saving Markdown reports to {output_dir.resolve()}...")
for report in reports:
path = output_dir / report_file_name(report.label, used_names)
if not report.error and not report.matches:
if path.exists():
path.unlink()
log_progress(f"Removed report with no removed APIs: {path.resolve()}")
else:
log_progress(f"Skipped report with no removed APIs: {path.resolve()}")
continue
path.write_text(markdown_report(report), encoding="utf-8")
log_progress(f"Saved report: {path.resolve()}")
paths.append(path)
return paths
def print_reports(reports: list[RepoReport]) -> None:
multiple = len(reports) > 1
for repo_index, report in enumerate(reports):
if multiple:
if repo_index:
print()
print(report.label)
if report.error:
print(f"ERROR: {report.error}")
continue
if not report.matches:
print("No removed APIs found.")
continue
for index, match in enumerate(report.matches, start=1):
print(
f"{index}. {match.path}:{match.line_number} - "
f"removed API {match.api} used; {replacement_hint(match.api)}; {docs_hint(match.api)}"
)
def main() -> int:
parser = build_arg_parser()
if len(sys.argv) == 1:
parser.print_help(sys.stderr)
return 2
args = parser.parse_args()
if args.list_apis:
for api in REMOVED_APIS:
print(list_api_entry(api))
return 0
try:
sources = resolve_sources(args)
except (RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if not sources:
print("One source option is required: --folder, --repo, --repos, or --org.", file=sys.stderr)
return 2
log_progress(f"Resolved {len(sources)} source{'' if len(sources) == 1 else 's'} to scan.")
suffixes = tuple(suffix.strip() for suffix in args.extensions.split(",") if suffix.strip())
if not suffixes:
print("No file extensions configured.", file=sys.stderr)
return 2
missing_dependencies = find_missing_dependencies(sources)
if missing_dependencies:
for command in missing_dependencies:
print(f"Missing dependency: `{command}`. {install_hint(command)}", file=sys.stderr)
return 2
patterns = compile_patterns(REMOVED_DOTTED_APIS)
reports = scan_sources(sources, suffixes, patterns, tuple(args.skip_dir), args.keep_temp)
print_reports(reports)
if args.report:
output_dir = Path(args.report).expanduser()
try:
report_paths = write_markdown_reports(reports, output_dir)
except OSError as error:
print(f"Could not write Markdown reports to {output_dir}: {error}", file=sys.stderr)
return 2
if report_paths:
log_progress(f"Markdown reports written to {output_dir.resolve()}")
else:
log_progress(f"No Markdown reports written to {output_dir.resolve()}")
if any(report.error for report in reports):
return 2
if args.fail_on_match and any(report.matches for report in reports):
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment