chore: add CI, lint config, login rate limit and split large modules
- Rate-limit failed logins (8 per 15 min per IP or username, 429 + Retry-After) - Add ruff/mypy config and GitHub Actions workflow (ruff, pytest, tsc, tests, build) - Apply ruff autofixes (import order, pyupgrade) - Extract rpy_helpers.py, engine-util.ts, TranslationParts.tsx and translation-types.ts from oversized modules - Add dependency-free React UI tests - Refresh README and ARCHITECTURE (Naninovel/Tomie, status and plans) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
57a4edcbfd
commit
bbbcc6a5d8
42 files changed
+678
-391
No files matched your search
@@ -1,14 +1,30 @@
|
||||
"""Ren'Py script (.rpy) → PGVN IR."""
|
||||
|
||||
import ast
|
||||
import re
|
||||
import warnings
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.converter import pyexpr
|
||||
from app.converter.pyexpr import Expr, Stmt, Unsupported, const
|
||||
from app.converter.rpy_helpers import (
|
||||
_CALL_LINE,
|
||||
_DISPLAYABLE_CALLS,
|
||||
_MEDIA_EXTS,
|
||||
_NUMBER_LINE,
|
||||
_SHOW_KEYWORDS,
|
||||
POSITION_PROPS,
|
||||
_assign_name,
|
||||
_atl_props,
|
||||
_const,
|
||||
_dedent,
|
||||
_dotted_name,
|
||||
_jump_or_call,
|
||||
_num,
|
||||
_parse_py,
|
||||
_split_top,
|
||||
_static_props,
|
||||
)
|
||||
from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree
|
||||
|
||||
Node = dict[str, Any]
|
||||
@@ -41,9 +57,6 @@ TRANSITION_CLASSES = {"Dissolve", "Fade", "ImageDissolve", "AlphaDissolve", "Pix
|
||||
|
||||
POSITIONS = {"left", "right", "center", "truecenter", "topleft", "topright", "top",
|
||||
"offscreenleft", "offscreenright", "default", "reset"}
|
||||
POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter",
|
||||
"ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset",
|
||||
"align", "pos", "anchor", "xysize", "size"}
|
||||
CHANNELS = {"music", "sound", "voice", "audio", "movie"}
|
||||
SKIP_BLOCKS = {"screen", "style", "translate", "testcase"}
|
||||
BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"}
|
||||
@@ -1100,7 +1113,7 @@ class RpyParser:
|
||||
if c.string_raw() is not None:
|
||||
c.pos = save
|
||||
break
|
||||
p = c.image_part() or (("@" if c.char("@") else None))
|
||||
p = c.image_part() or ("@" if c.char("@") else None)
|
||||
if p is None:
|
||||
c.pos = save
|
||||
break
|
||||
@@ -1130,119 +1143,3 @@ class RpyParser:
|
||||
c.rest()
|
||||
self.d.stats["say"] += 1
|
||||
return [node]
|
||||
|
||||
|
||||
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
|
||||
_NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$")
|
||||
_CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(")
|
||||
_DISPLAYABLE_CALLS = {"Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed",
|
||||
"ConditionSwitch", "Image", "im.Scale", "im.FactorScale", "im.Flip",
|
||||
"im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"}
|
||||
_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus",
|
||||
"flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"}
|
||||
|
||||
|
||||
def _dedent(source: str) -> str:
|
||||
import textwrap
|
||||
return textwrap.dedent(source)
|
||||
|
||||
|
||||
def _parse_py(source: str, mode: str = "exec") -> ast.AST:
|
||||
# decompiled scripts are full of "\{" in plain strings: don't spam SyntaxWarning
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
return ast.parse(_dedent(source), mode=mode)
|
||||
|
||||
|
||||
def _dotted_name(e: Expr) -> str:
|
||||
if e["k"] == "name":
|
||||
return e["id"]
|
||||
if e["k"] == "attr":
|
||||
return f"{_dotted_name(e['o'])}.{e['a']}"
|
||||
return ""
|
||||
|
||||
|
||||
def _assign_name(t: ast.AST) -> str | None:
|
||||
if isinstance(t, ast.Name):
|
||||
return t.id
|
||||
if isinstance(t, ast.Attribute):
|
||||
inner = _assign_name(t.value)
|
||||
return f"{inner}.{t.attr}" if inner else None
|
||||
return None
|
||||
|
||||
|
||||
def _const(e: Expr | None) -> Any:
|
||||
if e is None:
|
||||
return None
|
||||
if e.get("k") == "const":
|
||||
return e["v"]
|
||||
if e.get("k") in ("tuple", "list"):
|
||||
return [_const(x) for x in e["v"]]
|
||||
if e.get("k") == "unary" and e["op"] == "-":
|
||||
v = _const(e["v"])
|
||||
return -v if isinstance(v, (int, float)) else None
|
||||
return None
|
||||
|
||||
|
||||
def _num(s: str | None) -> float | None:
|
||||
try:
|
||||
return float(s) if s is not None else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _static_props(kw: dict[str, Expr]) -> dict[str, Any]:
|
||||
props = {}
|
||||
for k, v in kw.items():
|
||||
if k in POSITION_PROPS:
|
||||
val = _const(v)
|
||||
if val is not None:
|
||||
props[k] = val
|
||||
return props
|
||||
|
||||
|
||||
def _atl_props(text: str) -> dict[str, Any]:
|
||||
"""Extract static `prop value` pairs from one ATL line, ignoring timing."""
|
||||
tokens = text.replace(",", " ").split()
|
||||
props: dict[str, Any] = {}
|
||||
i = 0
|
||||
while i < len(tokens) - 1:
|
||||
key = tokens[i]
|
||||
if key in POSITION_PROPS:
|
||||
raw = tokens[i + 1]
|
||||
try:
|
||||
props[key] = ast.literal_eval(raw)
|
||||
except (ValueError, SyntaxError):
|
||||
if raw.startswith("(") and i + 2 < len(tokens):
|
||||
try:
|
||||
props[key] = list(ast.literal_eval(raw + "," + tokens[i + 2]))
|
||||
i += 1
|
||||
except (ValueError, SyntaxError):
|
||||
pass
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
return props
|
||||
|
||||
|
||||
def _split_top(source: str) -> list[str]:
|
||||
parts, depth, cur = [], 0, []
|
||||
for ch in source:
|
||||
if ch in "([{":
|
||||
depth += 1
|
||||
elif ch in ")]}":
|
||||
depth -= 1
|
||||
if ch == "," and depth == 0:
|
||||
parts.append("".join(cur))
|
||||
cur = []
|
||||
else:
|
||||
cur.append(ch)
|
||||
if "".join(cur).strip():
|
||||
parts.append("".join(cur))
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
|
||||
|
||||
def _jump_or_call(op: str, target: Expr) -> Node:
|
||||
if target.get("k") == "const":
|
||||
return {"op": op, "target": target["v"]}
|
||||
return {"op": op, "expr": target}
|
||||
Reference in new issue
Block a user