blob: 8a0a200b071013d85ba661cd2cbff30b7ebc2c5c [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Rebrand "nuvolaris" to "openserverless".
Walks all subfolders renaming every folder named with "nuvolaris" in it,
then every file whose name carries it (both with `git mv`, so the rename is
staged rather than looking like a delete plus an untracked copy). Renaming
the files is required, not cosmetic: a file name written inside another file
is rebranded as plain text, so the reference would otherwise point at a path
that no longer exists. Then walks all files replacing:
nuvolaris -> openserverless
Nuvolaris -> OpenServerless
NUVOLARIS -> OPENSERVERLESS
Any *other* casing (nuVolaris, NuVoLaRiS, ...) is NOT rewritten: it is
reported as a warning, in the log and on stderr, for a human to handle.
Pre-existing casings of the new name are accepted without warning when
they are one of the three common forms (openserverless, OpenServerless,
OPENSERVERLESS, plus Openserverless); anything else is warned about,
except the known "OPenServerless" spelling, whose lines are skipped.
A few files are excluded wholesale (.gitmodules anywhere, plus build-step.md
and olaris/prereq.yml, which carry upstream github URLs that must keep
resolving). Lines naming external resources that still exist under the old
name (nuvolaris-testing, registry.hub.docker.com/nuvolaris/, ghcr.io and
github.com repo references, @nuvolaris.io addresses) are left as they are.
Writes rebrander.log listing the folders and files renamed, every replaced line as
<file>:<line>:<newvalue>, and every line left as is by an exclusion rule
as SKIP:<file>:<line>:<oldvalue>.
By default nothing is modified: the script only reports what it would do.
Pass --do-it-for-real to actually rename folders and files and rewrite them.
Usage:
./rebrander.py [ROOT] [--do-it-for-real]
"""
import argparse
import os
import re
import subprocess
import sys
OLD = "nuvolaris"
NEW = "openserverless"
# The only two casings that get rewritten; everything else is warned about.
REPLACEMENTS = {
"nuvolaris": "openserverless",
"Nuvolaris": "OpenServerless",
"NUVOLARIS": "OPENSERVERLESS",
}
# Casings of the new name accepted as common usage; anything else is warned.
ACCEPTED_NEW = {"openserverless", "OpenServerless", "OPENSERVERLESS",
"Openserverless"}
LOG_NAME = "rebrander.log"
# Never descend into these; renaming/rewriting them corrupts the checkout.
SKIP_DIRS = {".git", ".hg", ".svn", "node_modules", "__pycache__",
".venv", "venv", ".mypy_cache", ".pytest_cache", ".ruff_cache"}
# Excluded files (spec): .gitmodules holds submodule URLs. Matched by base
# name, so nested submodule files are covered too.
SKIP_FILES = {".gitmodules"}
# Excluded files given as a path relative to the root: unlike the above these
# name one specific file, not every file that happens to share the base name
# (there are several prereq.yml in the tree; only olaris/ is excluded).
SKIP_PATHS = {"build-step.md", os.path.join("olaris", "prereq.yml")}
# This script and its own log must not rewrite themselves mid-run.
SELF_FILES = {LOG_NAME, os.path.basename(__file__)}
# Excluded lines (spec). These are left byte-for-byte as they are and logged
# under SKIP. The first three apply to every file; the github repo reference
# is excluded only inside .go files.
ODD_NEW_RE = re.compile(r"OPenServerless")
EMAIL_RE = re.compile(r"@nuvolaris\.io", re.IGNORECASE)
GHCR_RE = re.compile(r"ghcr\.io/nuvolaris/", re.IGNORECASE)
GO_REPO_RE = re.compile(r"github\.com/nuvolaris/", re.IGNORECASE)
# Live external resources registered under the old name: a GCP project /
# cluster and Docker Hub images. Renaming the string does not rename them.
TESTING_RE = re.compile(r"nuvolaris-testing", re.IGNORECASE)
DOCKERHUB_RE = re.compile(r"registry\.hub\.docker\.com/nuvolaris/", re.IGNORECASE)
# Go module files pin the same repo references as the .go sources do.
GO_MODULE_FILES = {"go.mod", "go.sum"}
ANY_FILE_EXCLUDES = (ODD_NEW_RE, TESTING_RE, DOCKERHUB_RE, EMAIL_RE, GHCR_RE)
MAX_BYTES = 20 * 1024 * 1024 # skip anything bigger; not source text
OLD_ANY = re.compile(OLD, re.IGNORECASE)
NEW_ANY = re.compile(NEW, re.IGNORECASE)
def substitute(text: str):
"""Replace the two accepted casings.
Returns (new_text, [odd casings of OLD left untouched]).
"""
odd = []
def repl(m):
word = m.group(0)
if word in REPLACEMENTS:
return REPLACEMENTS[word]
odd.append(word) # unknown casing: leave it alone, warn instead
return word
return OLD_ANY.sub(repl, text), odd
def odd_new_casings(text: str):
"""Occurrences of the new name in a casing we don't consider canonical."""
return [m.group(0) for m in NEW_ANY.finditer(text)
if m.group(0) not in ACCEPTED_NEW]
def line_excluded(path: str, line: str) -> bool:
"""Spec: skip lines carrying "OPenServerless", a nuvolaris-testing or
registry.hub.docker.com/nuvolaris/ reference, a @nuvolaris.io address or
a ghcr.io/nuvolaris/ reference, plus github.com/nuvolaris/ inside the go
sources and the module files that pin them."""
if any(rx.search(line) for rx in ANY_FILE_EXCLUDES):
return True
base = os.path.basename(path)
if not (path.endswith(".go") or base in GO_MODULE_FILES):
return False
return GO_REPO_RE.search(line) is not None
def is_binary(path: str) -> bool:
try:
with open(path, "rb") as f:
chunk = f.read(8000)
except OSError:
return True
return b"\0" in chunk
def prune(dirnames):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
def git_toplevel(path: str):
"""Repo that tracks `path`, or None if it is not under git control.
Submodules are separate repositories: a `git mv` has to run inside the one
that actually tracks the directory, not the outermost checkout.
"""
try:
out = subprocess.run(["git", "-C", path, "rev-parse", "--show-toplevel"],
capture_output=True, text=True, check=True)
except (OSError, subprocess.CalledProcessError):
return None
return out.stdout.strip() or None
def git_mv(old_path: str, new_path: str):
"""Move with `git mv` so the rename is staged, not seen as delete+untracked.
Returns None on success, else a message. This matters because the run also
rewrites .gitignore rules that name the very directories being renamed: an
unstaged destination can be swallowed by the rewritten rule and silently
dropped from the tree.
Used for both folders and files; git resolves the destination against the
repo that tracks the source, so nested submodules are handled correctly.
"""
repo = git_toplevel(os.path.dirname(old_path))
if repo is None:
return "not a git repository"
try:
r = subprocess.run(["git", "-C", repo, "mv", old_path, new_path],
capture_output=True, text=True)
except OSError as e:
return str(e)
if r.returncode == 0:
return None
return (r.stderr or r.stdout).strip() or f"git mv exited {r.returncode}"
def rename_folders(root: str, dry_run: bool, warnings):
"""Rename matching directories with `git mv`, deepest-first.
Deepest-first keeps every stored path valid: renaming a parent before its
child would invalidate the child's path.
"""
renamed = []
targets = []
for dirpath, dirnames, _ in os.walk(root):
prune(dirnames)
for d in dirnames:
if OLD_ANY.search(d):
targets.append(os.path.join(dirpath, d))
for old_path in sorted(targets, key=lambda p: p.count(os.sep), reverse=True):
parent, name = os.path.split(old_path)
new_name, odd = substitute(name)
rel_old = os.path.relpath(old_path, root)
for w in odd:
warnings.append((rel_old, 0, f"folder name has unhandled casing {w!r}"))
if new_name == name:
continue
new_path = os.path.join(parent, new_name)
if os.path.exists(new_path):
warnings.append((rel_old, 0, f"rename target already exists: {new_name!r}"))
continue
if not dry_run:
err = git_mv(old_path, new_path)
if err:
warnings.append((rel_old, 0, f"git mv failed: {err}"))
continue
renamed.append((rel_old, os.path.relpath(new_path, root)))
return renamed
def rename_files(root: str, dry_run: bool, warnings):
"""Rename matching files with `git mv`.
Runs after rename_folders so every path here is the post-rename one; a
file inside a renamed directory is reached under its new parent.
This is not optional: file names written inside other files are rebranded
as plain text, so leaving the files themselves alone would point those
references at paths that no longer exist.
"""
renamed = []
targets = []
for dirpath, dirnames, filenames in os.walk(root):
prune(dirnames)
for fn in filenames:
if fn in SELF_FILES:
continue
if OLD_ANY.search(fn):
targets.append(os.path.join(dirpath, fn))
for old_path in sorted(targets):
parent, name = os.path.split(old_path)
new_name, odd = substitute(name)
rel_old = os.path.relpath(old_path, root)
for w in odd:
warnings.append((rel_old, 0, f"file name has unhandled casing {w!r}"))
if new_name == name:
continue
if os.path.islink(old_path):
warnings.append((rel_old, 0, "symlink not renamed"))
continue
new_path = os.path.join(parent, new_name)
if os.path.exists(new_path):
warnings.append((rel_old, 0, f"rename target already exists: {new_name!r}"))
continue
if not dry_run:
err = git_mv(old_path, new_path)
if err:
warnings.append((rel_old, 0, f"git mv failed: {err}"))
continue
renamed.append((rel_old, os.path.relpath(new_path, root)))
return renamed
def rewrite_files(root: str, dry_run: bool, warnings):
"""Replace occurrences inside files; return per-file changed line pairs."""
changes = [] # (relpath, [(lineno, newvalue), ...])
skipped = [] # (relpath, [(lineno, oldvalue), ...]) - excluded by a rule
for dirpath, dirnames, filenames in os.walk(root):
prune(dirnames)
for fn in filenames:
if fn in SELF_FILES or fn in SKIP_FILES:
continue
path = os.path.join(dirpath, fn)
rel = os.path.relpath(path, root)
if rel in SKIP_PATHS:
continue
if os.path.islink(path):
continue
try:
if os.path.getsize(path) > MAX_BYTES:
continue
except OSError:
continue
if is_binary(path):
continue
try:
with open(path, "r", encoding="utf-8", newline="") as f:
lines = f.readlines()
except (UnicodeDecodeError, OSError):
continue
hits = []
skips = []
out = []
for i, line in enumerate(lines, 1):
if line_excluded(rel, line):
out.append(line)
# Only worth reporting if the rule actually held something
# back; an excluded line with nothing to replace is noise.
if substitute(line)[0] != line:
skips.append((i, line.rstrip("\r\n")))
continue
new_line, odd = substitute(line)
out.append(new_line)
for w in odd:
warnings.append((rel, i, f"unhandled casing {w!r} left as-is"))
for w in odd_new_casings(line):
warnings.append((rel, i, f"non-canonical {NEW!r} casing {w!r}"))
if new_line != line:
hits.append((i, new_line.rstrip("\r\n")))
if skips:
skipped.append((rel, skips))
if not hits:
continue
if not dry_run:
try:
with open(path, "w", encoding="utf-8", newline="") as f:
f.writelines(out)
except OSError as e:
warnings.append((rel, 0, f"write failed: {e}"))
continue
changes.append((rel, hits))
return changes, skipped
def write_log(root: str, renamed, renamed_files, changes, skipped, warnings,
dry_run: bool):
log_path = os.path.join(root, LOG_NAME)
total_lines = sum(len(h) for _, h in changes)
total_skipped = sum(len(h) for _, h in skipped)
bar = "=" * 70
with open(log_path, "w", encoding="utf-8") as log:
log.write(f"rebrander: {OLD} -> {NEW}\n")
log.write(f"root: {os.path.abspath(root)}\n")
if dry_run:
log.write("mode: DRY RUN - nothing modified "
"(pass --do-it-for-real to apply)\n")
else:
log.write("mode: APPLIED - changes written to disk\n")
log.write(f"folders renamed: {len(renamed)}\n")
log.write(f"files renamed: {len(renamed_files)}\n")
log.write(f"files changed: {len(changes)}\n")
log.write(f"lines replaced: {total_lines}\n")
log.write(f"lines left as is:{total_skipped}\n")
log.write(f"warnings: {len(warnings)}\n")
log.write(f"\n{bar}\nFOLDERS RENAMED\n{bar}\n")
if renamed:
for old, new in renamed:
log.write(f" {old}\n-> {new}\n")
else:
log.write(" (none)\n")
log.write(f"\n{bar}\nFILES RENAMED\n{bar}\n")
if renamed_files:
for old_rel, new_rel in renamed_files:
log.write(f" {old_rel}\n-> {new_rel}\n")
else:
log.write(" (none)\n")
log.write(f"\n{bar}\nWARNINGS\n{bar}\n")
if warnings:
for rel, lineno, msg in sorted(warnings):
where = f"{rel}:{lineno}" if lineno else rel
log.write(f" {where}: {msg}\n")
else:
log.write(" (none)\n")
log.write(f"\n{bar}\nLINES REPLACED\n{bar}\n")
if changes:
for relpath, hits in sorted(changes):
for lineno, after in hits:
log.write(f"{relpath}:{lineno}:{after}\n")
else:
log.write(" (none)\n")
log.write(f"\n{bar}\nLINES LEFT AS IS (excluded)\n{bar}\n")
if skipped:
for relpath, hits in sorted(skipped):
for lineno, before in hits:
log.write(f"SKIP:{relpath}:{lineno}:{before}\n")
else:
log.write(" (none)\n")
return log_path, total_lines, total_skipped
def main():
ap = argparse.ArgumentParser(description=f"Rebrand {OLD} -> {NEW}")
ap.add_argument("root", nargs="?", default=".", help="root folder (default: .)")
ap.add_argument("--do-it-for-real", action="store_true", dest="for_real",
help="actually rename and rewrite; without it nothing is "
"modified and only the log is produced")
args = ap.parse_args()
dry_run = not args.for_real
root = os.path.abspath(args.root)
if not os.path.isdir(root):
sys.exit(f"not a directory: {root}")
warnings = []
# Folders first, then files, so every `git mv` targets a path that still
# exists; the file walk then sees the final paths in the log.
renamed = rename_folders(root, dry_run, warnings)
renamed_files = rename_files(root, dry_run, warnings)
changes, skipped = rewrite_files(root, dry_run, warnings)
log_path, total_lines, total_skipped = write_log(
root, renamed, renamed_files, changes, skipped, warnings, dry_run)
for rel, lineno, msg in sorted(warnings)[:20]:
where = f"{rel}:{lineno}" if lineno else rel
print(f"WARN {where}: {msg}", file=sys.stderr)
if len(warnings) > 20:
print(f"WARN ... and {len(warnings) - 20} more (see {LOG_NAME})",
file=sys.stderr)
print(f"folders renamed: {len(renamed)}")
print(f"files renamed: {len(renamed_files)}")
print(f"files changed: {len(changes)}")
print(f"lines replaced: {total_lines}")
print(f"lines left as is:{total_skipped}")
print(f"warnings: {len(warnings)}")
print(f"log: {log_path}")
if dry_run:
print("(dry run - nothing was modified; "
"pass --do-it-for-real to apply)")
if __name__ == "__main__":
main()