|
#!/usr/bin/env python |
|
|
|
import argparse |
|
import sqlite3 |
|
import pathlib |
|
import xml.etree.ElementTree as ET |
|
import subprocess |
|
import sys |
|
|
|
import semver |
|
|
|
GODOT_DB = "godot_version_doc.db" |
|
|
|
db_connection: sqlite3.Connection | None = None |
|
|
|
|
|
def is_tool(name: str) -> bool: |
|
from shutil import which |
|
|
|
return which(name) is not None |
|
|
|
|
|
def print_error(*args, **kwargs): |
|
print(*args, **kwargs, file=sys.stderr) |
|
|
|
|
|
def connect_to_db() -> sqlite3.Connection: |
|
global GODOT_DB |
|
global db_connection |
|
db_connection = sqlite3.connect(GODOT_DB) |
|
return db_connection |
|
|
|
|
|
def init_db(force: bool): |
|
global GODOT_DB |
|
|
|
if force: |
|
godot_db = pathlib.Path(GODOT_DB) |
|
if godot_db.exists(): |
|
godot_db.unlink() |
|
|
|
cursor = connect_to_db().cursor() |
|
|
|
cursor.execute(""" |
|
CREATE TABLE IF NOT EXISTS classes ( |
|
class_id INTEGER PRIMARY KEY NOT NULL, |
|
name TEXT NOT NULL, |
|
path TEXT NOT NULL, |
|
UNIQUE (name, path) |
|
) |
|
""") |
|
cursor.execute(""" |
|
CREATE TABLE IF NOT EXISTS constructors( |
|
constructor_id INTEGER PRIMARY KEY NOT NULL, |
|
class_id INTEGER, |
|
name TEXT NOT NULL, |
|
UNIQUE (class_id, name), |
|
FOREIGN KEY (class_id) |
|
REFERENCES classes (class_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION |
|
) |
|
""") |
|
cursor.execute(""" |
|
CREATE TABLE IF NOT EXISTS methods ( |
|
method_id INTEGER PRIMARY KEY NOT NULL, |
|
class_id INTEGER, |
|
name TEXT NOT NULL, |
|
UNIQUE (class_id, name), |
|
FOREIGN KEY (class_id) |
|
REFERENCES classes (class_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION |
|
) |
|
""") |
|
cursor.execute(""" |
|
CREATE TABLE IF NOT EXISTS members ( |
|
member_id INTEGER PRIMARY KEY NOT NULL, |
|
class_id INTEGER, |
|
name TEXT NOT NULL, |
|
UNIQUE (class_id, name), |
|
FOREIGN KEY (class_id) |
|
REFERENCES classes (class_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION |
|
) |
|
""") |
|
cursor.execute(""" |
|
CREATE TABLE IF NOT EXISTS constants ( |
|
constant_id INTEGER PRIMARY KEY NOT NULL, |
|
class_id INTEGER, |
|
name TEXT NOT NULL, |
|
UNIQUE (class_id, name), |
|
FOREIGN KEY (class_id) |
|
REFERENCES classes (class_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION |
|
) |
|
""") |
|
cursor.execute(""" |
|
CREATE TABLE IF NOT EXISTS signals ( |
|
signal_id INTEGER PRIMARY KEY NOT NULL, |
|
class_id INTEGER, |
|
name TEXT NOT NULL, |
|
UNIQUE (class_id, name), |
|
FOREIGN KEY (class_id) |
|
REFERENCES classes (class_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION |
|
) |
|
""") |
|
cursor.execute(""" |
|
CREATE TABLE IF NOT EXISTS operators ( |
|
operator_id INTEGER PRIMARY KEY NOT NULL, |
|
class_id INTEGER, |
|
name TEXT NOT NULL, |
|
UNIQUE (class_id, name), |
|
FOREIGN KEY (class_id) |
|
REFERENCES classes (class_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION |
|
) |
|
""") |
|
cursor.execute(""" |
|
CREATE TABLE IF NOT EXISTS versions ( |
|
version_id INTEGER PRIMARY KEY NOT NULL, |
|
major INTEGER, |
|
minor INTEGER, |
|
patch INTEGER, |
|
UNIQUE (major, minor, patch) |
|
) |
|
""") |
|
cursor.execute(""" |
|
CREATE TABLE IF NOT EXISTS class_versions ( |
|
class_id INTEGER, |
|
version_id INTEGER, |
|
UNIQUE (class_id, version_id), |
|
PRIMARY KEY (class_id, version_id) |
|
FOREIGN KEY (class_id) |
|
REFERENCES classes (class_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION, |
|
FOREIGN KEY (version_id) |
|
REFERENCES versions (version_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION |
|
) |
|
""") |
|
cursor.execute(""" |
|
CREATE TABLE IF NOT EXISTS constructor_versions ( |
|
constructor_id INTEGER, |
|
version_id INTEGER, |
|
UNIQUE (constructor_id, version_id), |
|
PRIMARY KEY (constructor_id, version_id) |
|
FOREIGN KEY (constructor_id) |
|
REFERENCES constructors (constructor_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION, |
|
FOREIGN KEY (version_id) |
|
REFERENCES versions (version_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION |
|
) |
|
""") |
|
cursor.execute(""" |
|
CREATE TABLE IF NOT EXISTS method_versions ( |
|
method_id INTEGER, |
|
version_id INTEGER, |
|
UNIQUE (method_id, version_id), |
|
PRIMARY KEY (method_id, version_id) |
|
FOREIGN KEY (method_id) |
|
REFERENCES methods (method_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION, |
|
FOREIGN KEY (version_id) |
|
REFERENCES versions (version_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION |
|
) |
|
""") |
|
cursor.execute(""" |
|
CREATE TABLE IF NOT EXISTS member_versions ( |
|
member_id INTEGER, |
|
version_id INTEGER, |
|
UNIQUE (member_id, version_id), |
|
PRIMARY KEY (member_id, version_id) |
|
FOREIGN KEY (member_id) |
|
REFERENCES members (member_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION, |
|
FOREIGN KEY (version_id) |
|
REFERENCES versions (version_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION |
|
) |
|
""") |
|
cursor.execute(""" |
|
CREATE TABLE IF NOT EXISTS constant_versions ( |
|
constant_id INTEGER, |
|
version_id INTEGER, |
|
UNIQUE (constant_id, version_id), |
|
PRIMARY KEY (constant_id, version_id) |
|
FOREIGN KEY (constant_id) |
|
REFERENCES constants (constant_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION, |
|
FOREIGN KEY (version_id) |
|
REFERENCES versions (version_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION |
|
) |
|
""") |
|
cursor.execute(""" |
|
CREATE TABLE IF NOT EXISTS signal_versions ( |
|
signal_id INTEGER, |
|
version_id INTEGER, |
|
UNIQUE (signal_id, version_id), |
|
PRIMARY KEY (signal_id, version_id) |
|
FOREIGN KEY (signal_id) |
|
REFERENCES signals (signal_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION, |
|
FOREIGN KEY (version_id) |
|
REFERENCES versions (version_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION |
|
) |
|
""") |
|
cursor.execute(""" |
|
CREATE TABLE IF NOT EXISTS operator_versions ( |
|
operator_id INTEGER, |
|
version_id INTEGER, |
|
UNIQUE (operator_id, version_id), |
|
PRIMARY KEY (operator_id, version_id) |
|
FOREIGN KEY (operator_id) |
|
REFERENCES operators (operator_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION, |
|
FOREIGN KEY (version_id) |
|
REFERENCES versions (version_id) |
|
ON DELETE CASCADE |
|
ON UPDATE NO ACTION |
|
) |
|
""") |
|
|
|
|
|
def insert_entries_and_version(cursor: sqlite3.Cursor, entry_plural: str, entry_singular: str, root: ET.Element, class_id: int, version_id: int): |
|
global db_connection |
|
|
|
connection = db_connection |
|
if connection is None: |
|
return |
|
|
|
# Methods insert |
|
for entry in root.findall(f"./{entry_plural}/{entry_singular}"): |
|
params = { |
|
"class_id": class_id, |
|
"name": entry.attrib["name"] |
|
} |
|
|
|
result = cursor.execute(f""" |
|
INSERT INTO {entry_plural} (class_id, name) |
|
SELECT :class_id, :name |
|
WHERE NOT EXISTS( |
|
SELECT 1 FROM {entry_plural} |
|
WHERE |
|
class_id = :class_id |
|
AND name = :name |
|
) |
|
""", params) |
|
connection.commit() |
|
|
|
# Methods version insert |
|
for method in root.findall(f"./{entry_plural}/{entry_singular}"): |
|
params = { |
|
"class_id": class_id, |
|
"name": method.attrib["name"] |
|
} |
|
|
|
result = cursor.execute(f""" |
|
SELECT {entry_singular}_id FROM {entry_plural} |
|
WHERE |
|
class_id = :class_id |
|
AND name = :name |
|
""", params) |
|
fetch = result.fetchone() |
|
entry_id = fetch[0] |
|
|
|
result = cursor.execute(f""" |
|
INSERT INTO {entry_singular}_versions ({entry_singular}_id, version_id) |
|
SELECT :{entry_singular}_id, :version_id |
|
WHERE NOT EXISTS( |
|
SELECT 1 FROM {entry_singular}_versions |
|
WHERE |
|
{entry_singular}_id = :{entry_singular}_id |
|
AND version_id = :version_id |
|
) |
|
""", { |
|
f"{entry_singular}_id": entry_id, |
|
"version_id": version_id |
|
}) |
|
connection.commit() |
|
|
|
|
|
def parse(repo_path: pathlib.Path, version: semver.Version): |
|
connection = connect_to_db() |
|
cursor = connection.cursor() |
|
|
|
if not is_tool("git"): |
|
print_error("git not found") |
|
return |
|
|
|
if version.patch: |
|
branch_name = f"{version.major}.{version.minor}.{version.patch}-stable" |
|
else: |
|
branch_name = f"{version.major}.{version.minor}-stable" |
|
|
|
process_result = subprocess.run(["git", "checkout", branch_name], cwd=repo_path) |
|
if process_result.returncode > 0: |
|
print_error("Could not checkout on the repo") |
|
return |
|
|
|
version_params = { |
|
"major": version.major, |
|
"minor": version.minor, |
|
"patch": version.patch |
|
} |
|
result = cursor.execute(""" |
|
SELECT version_id FROM versions |
|
WHERE |
|
major = :major |
|
AND minor = :minor |
|
AND patch = :patch |
|
""", version_params) |
|
|
|
fetch = result.fetchone() |
|
if fetch is None: |
|
result = cursor.execute(""" |
|
INSERT INTO versions VALUES (null, :major, :minor, :patch) |
|
""", version_params) |
|
connection.commit() |
|
|
|
result = cursor.execute(""" |
|
SELECT version_id FROM versions |
|
WHERE |
|
major = :major |
|
AND minor = :minor |
|
AND patch = :patch |
|
""", version_params) |
|
fetch = result.fetchone() |
|
version_id = fetch[0] |
|
|
|
# Class insert |
|
classes_pathlist = sorted(repo_path.glob("doc/classes/*.xml")) |
|
for path in classes_pathlist: |
|
tree = ET.parse(path) |
|
root = tree.getroot() |
|
|
|
class_params = { |
|
"name": root.attrib["name"], |
|
"path": str(path.relative_to(repo_path)) |
|
} |
|
|
|
result = cursor.execute(""" |
|
INSERT INTO classes (name, path) |
|
SELECT :name, :path |
|
WHERE NOT EXISTS( |
|
SELECT 1 FROM classes |
|
WHERE |
|
name = :name |
|
AND path = :path |
|
) |
|
""", class_params) |
|
connection.commit() |
|
|
|
# Class loop |
|
classes_pathlist = sorted(repo_path.glob("doc/classes/*.xml")) |
|
for path in classes_pathlist: |
|
print(f"=> {str(path)}") |
|
|
|
tree = ET.parse(path) |
|
root = tree.getroot() |
|
|
|
class_params = { |
|
"name": root.attrib["name"], |
|
"path": str(path.relative_to(repo_path)) |
|
} |
|
|
|
result = cursor.execute(""" |
|
SELECT class_id FROM classes |
|
WHERE |
|
path = :path |
|
AND name = :name |
|
""", class_params) |
|
fetch = result.fetchone() |
|
class_id = fetch[0] |
|
|
|
# Class version insert |
|
class_version_params = { |
|
"class_id": class_id, |
|
"version_id": version_id |
|
} |
|
|
|
result = cursor.execute(""" |
|
INSERT INTO class_versions (class_id, version_id) |
|
SELECT :class_id, :version_id |
|
WHERE NOT EXISTS( |
|
SELECT 1 FROM class_versions |
|
WHERE |
|
class_id = :class_id |
|
AND version_id = :version_id |
|
) |
|
""", class_version_params) |
|
connection.commit() |
|
|
|
# Constructors insert |
|
for constructor in root.findall("./constructors/constructor"): |
|
params = [] |
|
for param in constructor.findall("./param"): |
|
params.append((param.attrib["name"], param.attrib["type"])) |
|
name = f"{constructor.attrib["name"]}({", ".join([f"{x[0]}: {x[1]}" for x in params])})" |
|
constructor_params = { |
|
"class_id": class_id, |
|
"name": name |
|
} |
|
|
|
result = cursor.execute(""" |
|
INSERT INTO constructors (class_id, name) |
|
SELECT :class_id, :name |
|
WHERE NOT EXISTS( |
|
SELECT 1 FROM constructors |
|
WHERE |
|
class_id = :class_id |
|
AND name = :name |
|
) |
|
""", constructor_params) |
|
connection.commit() |
|
|
|
# Constructors version insert |
|
for constructor in root.findall("./constructors/constructor"): |
|
params = [] |
|
for param in constructor.findall("./param"): |
|
params.append((param.attrib["name"], param.attrib["type"])) |
|
name = f"{constructor.attrib["name"]}({", ".join([f"{x[0]}: {x[1]}" for x in params])})" |
|
constructor_params = { |
|
"class_id": class_id, |
|
"name": name |
|
} |
|
|
|
result = cursor.execute(""" |
|
SELECT constructor_id FROM constructors |
|
WHERE |
|
class_id = :class_id |
|
AND name = :name |
|
""", constructor_params) |
|
fetch = result.fetchone() |
|
constructor_id = fetch[0] |
|
|
|
result = cursor.execute(""" |
|
INSERT INTO constructor_versions (constructor_id, version_id) |
|
SELECT :constructor_id, :version_id |
|
WHERE NOT EXISTS( |
|
SELECT 1 FROM constructor_versions |
|
WHERE |
|
constructor_id = :constructor_id |
|
AND version_id = :version_id |
|
) |
|
""", { |
|
"constructor_id": constructor_id, |
|
"version_id": version_id |
|
}) |
|
connection.commit() |
|
|
|
insert_entries_and_version(cursor, "methods", "method", root, class_id, version_id) |
|
insert_entries_and_version(cursor, "members", "member", root, class_id, version_id) |
|
insert_entries_and_version(cursor, "constants", "constant", root, class_id, version_id) |
|
insert_entries_and_version(cursor, "signals", "signal", root, class_id, version_id) |
|
insert_entries_and_version(cursor, "operators", "operator", root, class_id, version_id) |
|
|
|
process_result = subprocess.run(["git", "checkout", "-"], cwd=repo_path) |
|
if process_result.returncode > 0: |
|
print_error("Could not return to previous branch") |
|
return |
|
|
|
|
|
def parse_version(version_string: str) -> semver.Version: |
|
return semver.Version.parse(version_string, optional_minor_and_patch=True) |
|
|
|
|
|
if __name__ == "__main__": |
|
parser = argparse.ArgumentParser( |
|
prog="godot-api-intro", |
|
description="Checks the introduction version of features in Godot repositories") |
|
|
|
subparsers = parser.add_subparsers(dest="subcommand", help="subcommand help") |
|
|
|
init_parser = subparsers.add_parser("init", help="Inits the version database.") |
|
init_parser.add_argument("--force", dest="init_force", action="store_true") |
|
|
|
parse_parser = subparsers.add_parser("parse", help="Parses a version documentation to detect changes.") |
|
parse_parser.add_argument("repo", type=pathlib.Path) |
|
parse_parser.add_argument("version", type=parse_version) |
|
|
|
args = parser.parse_args() |
|
if hasattr(args, "subcommand"): |
|
if args.subcommand == "init": |
|
init_db(args.init_force) |
|
elif args.subcommand == "parse": |
|
parse(args.repo, args.version) |