feat:同步国服数据
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
"""Merge OpenAPI-generated layouts into runtime character modules.
|
||||
|
||||
Skills are matched strictly by their constant ``name``. Runtime Skill class
|
||||
identifiers and implementations are preserved; ``position`` and ``cellPos``
|
||||
come from the generated class. Generated-only skills are retained as commented blocks.
|
||||
The runtime header and profession-specific footer are preserved unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GENERATED_ROOT = ROOT / "backend" / "openapi" / "generate"
|
||||
CHARACTER_ROOT = ROOT / "backend" / "core" / "character" / "GF"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillBlock:
|
||||
class_name: str
|
||||
skill_name: str
|
||||
start: int
|
||||
end: int
|
||||
position: str | None
|
||||
cell_pos: str | None
|
||||
|
||||
|
||||
def _offsets(source: str) -> list[int]:
|
||||
result = [0]
|
||||
for line in source.splitlines(keepends=True):
|
||||
result.append(result[-1] + len(line))
|
||||
return result
|
||||
|
||||
|
||||
def _skills(source: str, path: Path) -> list[SkillBlock]:
|
||||
tree = ast.parse(source, filename=str(path))
|
||||
offsets = _offsets(source)
|
||||
result: list[SkillBlock] = []
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.ClassDef) or not re.fullmatch(r"Skill\d+", node.name):
|
||||
continue
|
||||
name = None
|
||||
position = None
|
||||
cell_pos = None
|
||||
for statement in node.body:
|
||||
if not isinstance(statement, (ast.Assign, ast.AnnAssign)):
|
||||
continue
|
||||
targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target]
|
||||
if len(targets) != 1 or not isinstance(targets[0], ast.Name):
|
||||
continue
|
||||
if targets[0].id == "name" and isinstance(statement.value, ast.Constant):
|
||||
name = statement.value.value
|
||||
elif targets[0].id == "position":
|
||||
position = ast.get_source_segment(source, statement.value)
|
||||
elif targets[0].id == "cellPos":
|
||||
cell_pos = ast.get_source_segment(source, statement.value)
|
||||
if not isinstance(name, str) or node.end_lineno is None:
|
||||
raise ValueError(f"{path}:{node.lineno}: invalid {node.name}")
|
||||
result.append(
|
||||
SkillBlock(
|
||||
node.name,
|
||||
name,
|
||||
offsets[node.lineno - 1],
|
||||
offsets[node.end_lineno],
|
||||
position,
|
||||
cell_pos,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _module_skill_bounds(source: str, path: Path) -> tuple[int, int]:
|
||||
"""Return the code-only header end and profession-footer start offsets."""
|
||||
tree = ast.parse(source, filename=str(path))
|
||||
offsets = _offsets(source)
|
||||
indexes = [
|
||||
index
|
||||
for index, node in enumerate(tree.body)
|
||||
if isinstance(node, ast.ClassDef) and re.fullmatch(r"Skill\d+", node.name)
|
||||
]
|
||||
if not indexes:
|
||||
raise ValueError(f"missing skills: {path}")
|
||||
first_index, last_index = indexes[0], indexes[-1]
|
||||
header_end = offsets[tree.body[first_index - 1].end_lineno] if first_index else 0
|
||||
footer_start = (
|
||||
offsets[tree.body[last_index + 1].lineno - 1]
|
||||
if last_index + 1 < len(tree.body)
|
||||
else len(source)
|
||||
)
|
||||
return header_end, footer_start
|
||||
|
||||
|
||||
def _set_generated_field(block: str, field: str, value: str | None) -> str:
|
||||
if value is None:
|
||||
return block
|
||||
assignment = re.compile(rf"(?m)^(\s*{re.escape(field)}\s*(?::[^=\n]+)?=\s*).*$")
|
||||
if assignment.search(block):
|
||||
return assignment.sub(rf"\g<1>{value}", block, count=1)
|
||||
return re.sub(r"(?m)^(\s*name\s*=.*)$", rf"\g<1>\n {field} = {value}", block, count=1)
|
||||
|
||||
|
||||
def _statement_key(statement: ast.stmt) -> str | None:
|
||||
if isinstance(statement, (ast.Assign, ast.AnnAssign)):
|
||||
targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target]
|
||||
if len(targets) == 1 and isinstance(targets[0], ast.Name):
|
||||
return targets[0].id
|
||||
return None
|
||||
|
||||
|
||||
def _is_data_key(key: str | None) -> bool:
|
||||
return bool(key and re.fullmatch(r"data\d+", key))
|
||||
|
||||
|
||||
def _sync_generated_assignment(
|
||||
runtime_block: str, generated_block: str, field: str
|
||||
) -> str:
|
||||
"""Replace all runtime definitions of one field with the generated one."""
|
||||
runtime_class = ast.parse(runtime_block).body[0]
|
||||
generated_class = ast.parse(generated_block).body[0]
|
||||
if not isinstance(runtime_class, ast.ClassDef) or not isinstance(generated_class, ast.ClassDef):
|
||||
return runtime_block
|
||||
generated_statement = next(
|
||||
(statement for statement in generated_class.body if _statement_key(statement) == field),
|
||||
None,
|
||||
)
|
||||
if generated_statement is None:
|
||||
return runtime_block
|
||||
|
||||
runtime_statements = [
|
||||
statement for statement in runtime_class.body if _statement_key(statement) == field
|
||||
]
|
||||
runtime_lines = runtime_block.splitlines(keepends=True)
|
||||
generated_lines = generated_block.splitlines(keepends=True)
|
||||
generated_source = generated_lines[
|
||||
generated_statement.lineno - 1:generated_statement.end_lineno
|
||||
]
|
||||
if runtime_statements:
|
||||
insertion_line = runtime_statements[0].lineno - 1
|
||||
for statement in sorted(runtime_statements, key=lambda item: item.lineno, reverse=True):
|
||||
runtime_lines[statement.lineno - 1:statement.end_lineno] = []
|
||||
else:
|
||||
anchor = next(
|
||||
(
|
||||
statement
|
||||
for key in ("custom", "hasUP", "hasVP", "uuid")
|
||||
for statement in reversed(runtime_class.body)
|
||||
if _statement_key(statement) == key
|
||||
),
|
||||
None,
|
||||
)
|
||||
insertion_line = anchor.end_lineno if anchor is not None else 1
|
||||
generated_source = ["\n", *generated_source]
|
||||
runtime_lines[insertion_line:insertion_line] = generated_source
|
||||
return "".join(runtime_lines)
|
||||
|
||||
|
||||
def _sync_generated_data(runtime_block: str, generated_block: str) -> str:
|
||||
"""Rebuild dataN/hitN in numeric order using generated data definitions."""
|
||||
runtime_class = ast.parse(runtime_block).body[0]
|
||||
generated_class = ast.parse(generated_block).body[0]
|
||||
if not isinstance(runtime_class, ast.ClassDef) or not isinstance(generated_class, ast.ClassDef):
|
||||
return runtime_block
|
||||
runtime_lines = runtime_block.splitlines(keepends=True)
|
||||
generated_lines = generated_block.splitlines(keepends=True)
|
||||
runtime_items = {
|
||||
_statement_key(statement): (index, statement)
|
||||
for index, statement in enumerate(runtime_class.body)
|
||||
if _statement_key(statement)
|
||||
}
|
||||
generated_items = {
|
||||
_statement_key(statement): (index, statement)
|
||||
for index, statement in enumerate(generated_class.body)
|
||||
if _statement_key(statement)
|
||||
}
|
||||
selected_statements = [
|
||||
(index, statement, _statement_key(statement))
|
||||
for index, statement in enumerate(runtime_class.body)
|
||||
if _is_data_key(_statement_key(statement))
|
||||
or re.fullmatch(r"hit\d+", _statement_key(statement) or "")
|
||||
]
|
||||
generated_data = {key: value for key, value in generated_items.items() if _is_data_key(key)}
|
||||
if not selected_statements and not generated_data:
|
||||
return runtime_block
|
||||
|
||||
def numeric_key(key: str) -> int:
|
||||
return int(re.search(r"\d+$", key).group())
|
||||
|
||||
def segment(
|
||||
lines: list[str], class_node: ast.ClassDef, index: int, statement: ast.stmt
|
||||
) -> list[str]:
|
||||
previous_end = class_node.body[index - 1].end_lineno if index else class_node.lineno
|
||||
return lines[previous_end:statement.end_lineno]
|
||||
|
||||
# Remove every old dataN/hitN statement together with its leading comment
|
||||
# trivia. They are re-emitted below in deterministic numeric order.
|
||||
removals: list[tuple[int, int]] = []
|
||||
for index, statement, _ in selected_statements:
|
||||
previous_end = runtime_class.body[index - 1].end_lineno if index else runtime_class.lineno
|
||||
removals.append((previous_end, statement.end_lineno))
|
||||
if removals:
|
||||
insertion_line = min(start for start, _ in removals)
|
||||
else:
|
||||
insertion_anchor = next(
|
||||
(
|
||||
statement
|
||||
for statement in runtime_class.body
|
||||
if _statement_key(statement) == "associate"
|
||||
or isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
),
|
||||
None,
|
||||
)
|
||||
insertion_line = (
|
||||
insertion_anchor.lineno - 1
|
||||
if insertion_anchor is not None
|
||||
else runtime_class.end_lineno
|
||||
)
|
||||
for start, end in sorted(removals, reverse=True):
|
||||
runtime_lines[start:end] = []
|
||||
|
||||
data_keys = sorted(
|
||||
set(generated_data) | {key for key in runtime_items if _is_data_key(key)},
|
||||
key=numeric_key,
|
||||
)
|
||||
rebuilt: list[str] = []
|
||||
emitted_hits: set[str] = set()
|
||||
for data_key in data_keys:
|
||||
source_items = generated_items if data_key in generated_data else runtime_items
|
||||
source_lines = generated_lines if data_key in generated_data else runtime_block.splitlines(keepends=True)
|
||||
source_class = generated_class if data_key in generated_data else runtime_class
|
||||
index, statement = source_items[data_key]
|
||||
rebuilt.extend(segment(source_lines, source_class, index, statement))
|
||||
|
||||
hit_key = f"hit{numeric_key(data_key)}"
|
||||
if hit_key in runtime_items:
|
||||
_, hit_statement = runtime_items[hit_key]
|
||||
rebuilt.extend(runtime_block.splitlines(keepends=True)[hit_statement.lineno - 1:hit_statement.end_lineno])
|
||||
emitted_hits.add(hit_key)
|
||||
|
||||
# Retain unusual hitN fields even when no matching dataN exists.
|
||||
for hit_key in sorted(
|
||||
(
|
||||
key
|
||||
for key in runtime_items
|
||||
if re.fullmatch(r"hit\d+", key) and key not in emitted_hits
|
||||
),
|
||||
key=numeric_key,
|
||||
):
|
||||
_, hit_statement = runtime_items[hit_key]
|
||||
rebuilt.extend(runtime_block.splitlines(keepends=True)[hit_statement.lineno - 1:hit_statement.end_lineno])
|
||||
|
||||
runtime_lines[insertion_line:insertion_line] = rebuilt
|
||||
return "".join(runtime_lines)
|
||||
|
||||
|
||||
def _sync_generated_comments(runtime_block: str, generated_block: str) -> str:
|
||||
"""Sync generated docstrings and leading field comments into runtime code."""
|
||||
runtime_class = ast.parse(runtime_block).body[0]
|
||||
generated_class = ast.parse(generated_block).body[0]
|
||||
if not isinstance(runtime_class, ast.ClassDef) or not isinstance(generated_class, ast.ClassDef):
|
||||
return runtime_block
|
||||
|
||||
runtime_lines = runtime_block.splitlines(keepends=True)
|
||||
generated_lines = generated_block.splitlines(keepends=True)
|
||||
replacements: list[tuple[int, int, list[str]]] = []
|
||||
|
||||
# The generated API description is authoritative, but executable methods
|
||||
# and assignments continue to come from the runtime implementation.
|
||||
runtime_doc = runtime_class.body[0] if runtime_class.body and isinstance(runtime_class.body[0], ast.Expr) and isinstance(runtime_class.body[0].value, ast.Constant) and isinstance(runtime_class.body[0].value.value, str) else None
|
||||
generated_doc = generated_class.body[0] if generated_class.body and isinstance(generated_class.body[0], ast.Expr) and isinstance(generated_class.body[0].value, ast.Constant) and isinstance(generated_class.body[0].value.value, str) else None
|
||||
if runtime_doc is not None and generated_doc is not None:
|
||||
replacements.append(
|
||||
(runtime_doc.lineno - 1, runtime_doc.end_lineno, generated_lines[generated_doc.lineno - 1:generated_doc.end_lineno])
|
||||
)
|
||||
elif runtime_doc is None and generated_doc is not None:
|
||||
# Insert the generated skill description immediately below the class
|
||||
# declaration when the old runtime class had no docstring.
|
||||
replacements.append(
|
||||
(
|
||||
runtime_class.lineno,
|
||||
runtime_class.lineno,
|
||||
generated_lines[generated_doc.lineno - 1:generated_doc.end_lineno],
|
||||
)
|
||||
)
|
||||
|
||||
generated_by_key = {_statement_key(statement): (index, statement) for index, statement in enumerate(generated_class.body) if _statement_key(statement)}
|
||||
for runtime_index, runtime_statement in enumerate(runtime_class.body):
|
||||
key = _statement_key(runtime_statement)
|
||||
if key not in generated_by_key:
|
||||
continue
|
||||
generated_index, generated_statement = generated_by_key[key]
|
||||
runtime_previous_end = runtime_class.body[runtime_index - 1].end_lineno if runtime_index else runtime_class.lineno
|
||||
generated_previous_end = generated_class.body[generated_index - 1].end_lineno if generated_index else generated_class.lineno
|
||||
generated_trivia = generated_lines[generated_previous_end:generated_statement.lineno - 1]
|
||||
# Only replace trivia when it carries actual generated comments. Blank
|
||||
# spacing alone should not disturb hand-formatted runtime code.
|
||||
if any(line.lstrip().startswith("#") for line in generated_trivia):
|
||||
replacements.append(
|
||||
(runtime_previous_end, runtime_statement.lineno - 1, generated_trivia)
|
||||
)
|
||||
|
||||
for start, end, replacement in sorted(replacements, reverse=True):
|
||||
runtime_lines[start:end] = replacement
|
||||
return "".join(runtime_lines)
|
||||
|
||||
|
||||
def _append_missing_methods(
|
||||
runtime_block: str, generated_block: str, method_names: set[str]
|
||||
) -> str:
|
||||
"""Append generated class methods that the runtime skill does not define."""
|
||||
runtime_class = ast.parse(runtime_block).body[0]
|
||||
generated_class = ast.parse(generated_block).body[0]
|
||||
if not isinstance(runtime_class, ast.ClassDef) or not isinstance(generated_class, ast.ClassDef):
|
||||
return runtime_block
|
||||
existing = {
|
||||
statement.name
|
||||
for statement in runtime_class.body
|
||||
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
}
|
||||
generated_lines = generated_block.splitlines(keepends=True)
|
||||
additions: list[str] = []
|
||||
for statement in generated_class.body:
|
||||
if not isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
if statement.name not in method_names or statement.name in existing:
|
||||
continue
|
||||
additions.extend(generated_lines[statement.lineno - 1:statement.end_lineno])
|
||||
existing.add(statement.name)
|
||||
if not additions:
|
||||
return runtime_block
|
||||
return runtime_block.rstrip() + "\n\n" + "".join(additions).rstrip() + "\n"
|
||||
|
||||
|
||||
def _ensure_get_data_import(source: str) -> str:
|
||||
if not re.search(r"(?m)^\s*data\d+\s*=\s*get_data\(", source):
|
||||
return source
|
||||
pattern = re.compile(r"(?m)^(from\s+core\.basic\.skill\s+import\s+)([^\n]+)$")
|
||||
match = pattern.search(source)
|
||||
if match is None:
|
||||
raise ValueError("generated data requires a core.basic.skill import")
|
||||
imports = [item.strip() for item in match.group(2).split(",")]
|
||||
if "get_data" in imports:
|
||||
return source
|
||||
imports.append("get_data")
|
||||
return source[:match.start()] + match.group(1) + ", ".join(imports) + source[match.end():]
|
||||
|
||||
|
||||
def _sync_module_assignment(
|
||||
runtime_source: str, generated_source: str, field: str
|
||||
) -> str:
|
||||
"""Make a module-level assignment match the generated module."""
|
||||
runtime_tree = ast.parse(runtime_source)
|
||||
generated_tree = ast.parse(generated_source)
|
||||
generated_statement = next(
|
||||
(
|
||||
statement
|
||||
for statement in generated_tree.body
|
||||
if _statement_key(statement) == field
|
||||
),
|
||||
None,
|
||||
)
|
||||
if generated_statement is None:
|
||||
return runtime_source
|
||||
runtime_statements = [
|
||||
statement
|
||||
for statement in runtime_tree.body
|
||||
if _statement_key(statement) == field
|
||||
]
|
||||
runtime_lines = runtime_source.splitlines(keepends=True)
|
||||
generated_lines = generated_source.splitlines(keepends=True)
|
||||
assignment = generated_lines[
|
||||
generated_statement.lineno - 1:generated_statement.end_lineno
|
||||
]
|
||||
if runtime_statements:
|
||||
insertion_line = runtime_statements[0].lineno - 1
|
||||
for statement in sorted(runtime_statements, key=lambda item: item.lineno, reverse=True):
|
||||
runtime_lines[statement.lineno - 1:statement.end_lineno] = []
|
||||
replacement = assignment
|
||||
else:
|
||||
imports = [
|
||||
statement
|
||||
for statement in runtime_tree.body
|
||||
if isinstance(statement, (ast.Import, ast.ImportFrom))
|
||||
]
|
||||
insertion_line = imports[-1].end_lineno if imports else 0
|
||||
replacement = [*assignment, "\n"]
|
||||
runtime_lines[insertion_line:insertion_line] = replacement
|
||||
return "".join(runtime_lines)
|
||||
|
||||
|
||||
def _comment(block: str, name: str) -> str:
|
||||
lines = [f"# MIGRATION: character 中没有同名技能“{name}”,暂不启用\n"]
|
||||
lines.extend("# " + line.rstrip() + "\n" if line.strip() else "#\n" for line in block.splitlines())
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def merge(generated_path: Path, character_path: Path) -> tuple[str, dict[str, int]]:
|
||||
generated = generated_path.read_text(encoding="utf-8")
|
||||
character = character_path.read_text(encoding="utf-8")
|
||||
generated_skills = _skills(generated, generated_path)
|
||||
character_skills = _skills(character, character_path)
|
||||
if not generated_skills or not character_skills:
|
||||
raise ValueError(f"missing skills: {generated_path} or {character_path}")
|
||||
runtime = {skill.skill_name: skill for skill in character_skills}
|
||||
if len(runtime) != len(character_skills):
|
||||
raise ValueError(f"duplicate skill name: {character_path}")
|
||||
|
||||
character_header_end, character_footer_start = _module_skill_bounds(character, character_path)
|
||||
generated_header_end, _ = _module_skill_bounds(generated, generated_path)
|
||||
chunks = [character[:character_header_end].rstrip() + "\n\n"]
|
||||
chunks.append(generated[generated_header_end:generated_skills[0].start])
|
||||
cursor = generated_skills[0].start
|
||||
matched = commented = 0
|
||||
for index, generated_skill in enumerate(generated_skills):
|
||||
if index:
|
||||
chunks.append(generated[cursor:generated_skill.start])
|
||||
runtime_skill = runtime.get(generated_skill.skill_name)
|
||||
if runtime_skill is None:
|
||||
chunks.append(_comment(generated[generated_skill.start:generated_skill.end], generated_skill.skill_name))
|
||||
commented += 1
|
||||
else:
|
||||
block = character[runtime_skill.start:runtime_skill.end]
|
||||
generated_block = generated[generated_skill.start:generated_skill.end]
|
||||
block = _sync_generated_assignment(block, generated_block, "vps")
|
||||
block = _sync_generated_data(block, generated_block)
|
||||
block = _sync_generated_comments(block, generated_block)
|
||||
block = _set_generated_field(block, "position", generated_skill.position)
|
||||
block = _set_generated_field(block, "cellPos", generated_skill.cell_pos)
|
||||
block = _append_missing_methods(block, generated_block, {"vp_1", "vp_2"})
|
||||
chunks.append(block.rstrip() + "\n")
|
||||
matched += 1
|
||||
cursor = generated_skill.end
|
||||
chunks.append("\n" + character[character_footer_start:].lstrip("\r\n"))
|
||||
merged = "".join(chunks)
|
||||
merged = "\n".join(line.rstrip() for line in merged.splitlines()) + "\n"
|
||||
merged = _sync_module_assignment(merged, generated, "prefix")
|
||||
merged = _ensure_get_data_import(merged)
|
||||
return merged, {
|
||||
"generated": len(generated_skills), "runtime": len(character_skills),
|
||||
"matched": matched, "commented": commented,
|
||||
"runtime_only": len(character_skills) - matched,
|
||||
}
|
||||
|
||||
|
||||
def _atomic_write(path: Path, content: str) -> None:
|
||||
"""Write beside the target and atomically replace it on Windows.
|
||||
|
||||
Writing an already-open Python source file directly can intermittently
|
||||
produce ``OSError: [Errno 22]`` on Windows. A sibling temporary file also
|
||||
prevents a failed write from leaving a partially truncated module.
|
||||
"""
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_bytes(content.encode("utf-8"))
|
||||
for attempt in range(3):
|
||||
try:
|
||||
os.replace(temporary, path)
|
||||
return
|
||||
except OSError:
|
||||
if attempt == 2:
|
||||
raise
|
||||
time.sleep(0.1 * (attempt + 1))
|
||||
except OSError as error:
|
||||
raise OSError(
|
||||
error.errno,
|
||||
f"failed to replace {path} using temporary file {temporary}: {error.strerror}",
|
||||
str(path),
|
||||
) from error
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--check", action="store_true", help="validate without writing")
|
||||
args = parser.parse_args()
|
||||
totals = {key: 0 for key in ("files", "generated", "runtime", "matched", "commented", "runtime_only")}
|
||||
for generated_path in sorted(GENERATED_ROOT.rglob("*.py")):
|
||||
character_path = CHARACTER_ROOT / generated_path.relative_to(GENERATED_ROOT)
|
||||
if not character_path.exists():
|
||||
print(f"SKIP {generated_path.relative_to(GENERATED_ROOT)}: no runtime profession")
|
||||
continue
|
||||
output, stats = merge(generated_path, character_path)
|
||||
ast.parse(output, filename=str(character_path))
|
||||
if not args.check:
|
||||
_atomic_write(character_path, output)
|
||||
totals["files"] += 1
|
||||
for key, value in stats.items():
|
||||
totals[key] += value
|
||||
print(f"{'CHECK' if args.check else 'WRITE'} {generated_path.relative_to(GENERATED_ROOT)}: "
|
||||
f"matched={stats['matched']} commented={stats['commented']} runtime_only={stats['runtime_only']}")
|
||||
print("TOTAL " + " ".join(f"{key}={value}" for key, value in totals.items()))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Batch-remove every line containing ``# REMOVE`` from character modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CHARACTER_ROOT = ROOT / "backend" / "core" / "character"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--check", action="store_true", help="report only; do not write")
|
||||
args = parser.parse_args()
|
||||
files = removed_total = 0
|
||||
for path in sorted(CHARACTER_ROOT.rglob("*.py")):
|
||||
source = path.read_text(encoding="utf-8")
|
||||
lines = source.splitlines(keepends=True)
|
||||
kept = [line for line in lines if "# REMOVE" not in line]
|
||||
removed = len(lines) - len(kept)
|
||||
if not removed:
|
||||
continue
|
||||
output = "".join(kept)
|
||||
ast.parse(output, filename=str(path))
|
||||
files += 1
|
||||
removed_total += removed
|
||||
print(f"{'CHECK' if args.check else 'WRITE'} {path.relative_to(ROOT)}: {removed}")
|
||||
if not args.check:
|
||||
path.write_text(output, encoding="utf-8", newline="\n")
|
||||
print(f"TOTAL files={files} lines={removed_total}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user