Files
playbook/tools/tsl-codegen/scripts/enrich_metadata.py
T

906 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Conservatively enrich TSL API descriptions and search tags.
Markdown is the fact source. This tool never changes API headings, signatures,
parameter tables, return types, examples, or scope placement. It only updates
the first prose description line and the optional ``<!-- tags: ... -->`` line.
"""
from __future__ import annotations
import argparse
import csv
import html
import re
import sys
import unicodedata
from collections import defaultdict
from dataclasses import dataclass, field, replace
from pathlib import Path
from bs4 import BeautifulSoup
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from api_markdown import DECLARATION_LINE_RE, iter_api_entries
TAGS_RE = re.compile(r"^<!--\s*tags:\s*(.*?)\s*-->$")
ACCESS_RE = re.compile(r"^访问[:]\s*(.*?)\s*$", re.IGNORECASE)
H1_RE = re.compile(r"^#(?!#)\s+(.+?)\s*$")
TERMINAL_PUNCTUATION_RE = re.compile(r"[。..!??;;,:]+$")
MULTISPACE_RE = re.compile(r"\s+")
FORMULA_RE = re.compile(
r"^([^。;;,]{2,40}?)\s*(?<![<>=!])=(?!=)\s*(.+)$"
)
COMPARISON_RE = re.compile(r"<=|>=|==|!=|<|>")
RELATED_FUNCTION_RE = re.compile(
r"^([A-Za-z][A-Za-z0-9_]*)\s*相关函数$"
)
ACRONYM_RE = re.compile(r"(?<![A-Za-z0-9_])([A-Z][A-Z0-9_]{1,15})(?![A-Za-z0-9_])")
ACTION_CLAUSE_RE = re.compile(
r"^(?:用于|如果|由于|若(?!干)|支持|查询|输入|按照|按(?!钮)|与系统参数|"
r"对.+?(?:进行|使用)|从.+?(?:获取|读取|计算|返回)|"
r"由.+?(?:倒推|获取|计算|得到|生成))"
)
PROHIBITED_PROCESS_RE = re.compile(
r"Source\s*ID|来源[:]|已验证|验证通过|not[_ ]?found|"
r"测试过程|探测结果|官网定义",
re.IGNORECASE,
)
GENERIC_SUMMARIES = {
"函数",
"方法",
"属性",
"常量",
"接口",
"对象",
"类",
"内置函数",
"内部函数",
"统计分布相关函数",
}
GENERIC_TAGS = {
"函数",
"方法",
"属性",
"常量",
"数据",
"tsl",
"builtin",
"dotnet",
"third",
"deprecated",
"function",
"method",
"property",
}
CLASS_DESCRIPTIONS = {
"imap": "用于连接 IMAP 服务器、管理邮箱并读取邮件的内置对象",
"mailmsg": "用于创建、解析、保存和发送邮件消息的内置对象",
"messagepart": "表示邮件正文、附件等消息分段的内置对象",
"pop3": "用于连接 POP3 服务器并读取或删除邮件的内置对象",
"smtp": "用于连接 SMTP 服务器并发送邮件的内置对象",
"tcipher": "提供对称加密和解密能力的内置对象",
"tcookie": "表示 HTTP Cookie 名称、值和属性的内置对象",
"tcookiecollection": "用于创建、查找和管理 HTTP Cookie 集合的内置对象",
"tfilestream": "以文件为存储后端并提供流式读写能力的内置对象",
"thandlestream": "封装系统句柄并提供流式读写能力的内置对象",
"thashedstringlist": "使用哈希索引加速查找的字符串列表内置对象",
"tinifile": "用于读取和写入 INI 配置文件的内置对象",
"tmeminifile": "在内存中读取、修改和保存 INI 配置的内置对象",
"tmemorystream": "以内存缓冲区保存和操作流数据的内置对象",
"tregistryinifile": "以系统注册表为存储后端的 INI 配置内置对象",
"trsa": "提供 RSA 非对称加密、解密和签名能力的内置对象",
"tsession": "表示服务端会话及其键值数据的内置对象",
"tsessionman": "用于创建、查找、维护和删除服务端会话的内置对象",
"tstream": "定义顺序读写、定位和复制操作的流基类",
"tstringlist": "提供字符串集合存储、查找、排序和名称值管理能力的内置对象",
"twebrequest": "封装 HTTP 请求头、请求参数、Cookie 和请求正文的内置对象",
"twebresponse": "用于设置 HTTP 状态、响应头、Cookie 和响应正文的内置对象",
}
GENERIC_DESCRIPTION_OVERRIDES = {
"unicodeesc2": "将字符串转换为 \\uxxxx 形式的 Unicode 编码串",
"sf_x2inv": "根据累计概率计算卡方分布的逆函数值",
"sf_tinv": "根据累计概率计算 t 分布的逆函数值",
"sf_sinx": "计算正弦积分",
"sf_normalinv2": "根据累计概率计算标准正态分布的逆函数值",
"sf_normalinv": "根据累计概率计算正态分布的逆函数值",
"sf_normal": "计算正态分布函数值",
"sf_gammainv": "根据累计概率计算标准 Gamma 分布的逆函数值",
"sf_gamma": "计算伽马函数值",
"sf_finv": "根据累计概率计算 F 分布的逆函数值",
"sf_ex": "计算指数积分",
"sf_error": "计算误差函数(概率函数)值",
"sf_cosx": "计算余弦积分",
"sf_betainv": "根据累计概率计算贝塔分布的逆函数值",
"sf_beta": "计算贝塔函数值",
"readfile": "读取本地文件中的数据",
"millisecondspan": "计算两个日期时间之间相差的毫秒数",
"importfile": "从文件中导入数据",
"getmsgdigest": "计算字符串或文件的摘要并返回摘要字符串",
"exportfile": "将数据导出到文件",
}
AWKWARD_DESCRIPTION_OVERRIDES = {
"encodegraph": "根据指定的类型、名称、数据和属性数组生成图形并返回",
"slndepreciation": (
"按直线折旧法计算每期折旧额,计算公式为"
"(资产原值 Cost - 资产残值 Salvage/ 折旧周期数 Life"
),
"format": "根据 formatstr 指定的格式将参数连接成字符串并返回",
"ansiformat": "根据 formatstr 指定的格式将参数连接成字符串并返回",
"dupestring": "将字符串 AText 重复 ACount 次并返回结果",
"stockpjcj": "返回区间平均成交价,计算公式为区间成交金额 / 区间成交量",
}
LEADING_VERBS = (
"返回",
"获取",
"读取",
"写入",
"设置",
"创建",
"删除",
"移除",
"清除",
"添加",
"插入",
"查找",
"搜索",
"判断",
"检查",
"验证",
"计算",
"转换",
"解析",
"格式化",
"比较",
"执行",
"调用",
"连接",
"关闭",
"打开",
"发送",
"接收",
"保存",
"加载",
"列出",
"表示",
"提供",
"释放",
"交换",
"排序",
"过滤",
"统计",
"控制",
"指示",
"提取",
"根据",
"将",
"把",
"生成",
"构造",
"采用",
"利用",
"输出",
"得到",
"获得",
"取得",
"新建",
)
@dataclass(frozen=True)
class SourceRecord:
corpus: str
page_id: str
title: str
page: Path
description: str = ""
@dataclass(frozen=True)
class EntryContext:
name: str
signature: str
kind: str
scope: str
page: str
summary: str
access: str = ""
owner: str = ""
tags: tuple[str, ...] = ()
@dataclass(frozen=True)
class PageContext:
title: str
path: str
@dataclass
class AuditChange:
page: str
name: str
kind: str
old_description: str
new_description: str
old_tags: tuple[str, ...]
new_tags: tuple[str, ...]
sources: tuple[str, ...]
@dataclass
class Audit:
entries: int = 0
source_matches: int = 0
ambiguous_sources: int = 0
changed_descriptions: int = 0
changed_tags: int = 0
changes: list[AuditChange] = field(default_factory=list)
def merge(self, other: "Audit") -> None:
self.entries += other.entries
self.source_matches += other.source_matches
self.ambiguous_sources += other.ambiguous_sources
self.changed_descriptions += other.changed_descriptions
self.changed_tags += other.changed_tags
self.changes.extend(other.changes)
def normalize_key(value: str) -> str:
return unicodedata.normalize("NFKC", value).casefold().strip()
def clean_text(value: str) -> str:
value = html.unescape(value).replace("\xa0", " ")
value = value.replace("“", '"').replace("”", '"')
value = value.replace("", "'").replace("", "'")
return MULTISPACE_RE.sub(" ", value).strip()
def strip_terminal_punctuation(value: str) -> str:
return TERMINAL_PUNCTUATION_RE.sub("", clean_text(value)).rstrip()
def safe_source_description(value: str) -> str:
value = strip_terminal_punctuation(value)
if not value or PROHIBITED_PROCESS_RE.search(value):
return ""
return value
def _extract_source_description(page: Path) -> str:
try:
soup = BeautifulSoup(page.read_text(encoding="utf-8"), "html.parser")
except (OSError, UnicodeError):
return ""
root = soup.select_one("#help_content") or soup
for expected in ("简述", "说明"):
for label in root.select(".DescriteTitle"):
if clean_text(label.get_text(" ", strip=True)) != expected:
continue
marker = label.parent if label.parent is not None else label
sibling = marker.find_next_sibling()
if sibling is None:
sibling = label.find_next_sibling()
if sibling is not None:
return safe_source_description(sibling.get_text(" ", strip=True))
return ""
class SourceCatalog:
PREFERENCES = {
"builtin": ("tsl_base", "net_function", "knowledge_base"),
"third": ("net_function", "tsl_base", "knowledge_base"),
"deprecated": ("net_function", "tsl_base", "knowledge_base"),
"dotnet": ("net_function", "tsl_base", "knowledge_base"),
}
def __init__(self, records: dict[str, list[SourceRecord]] | None = None):
self.records = records or {}
self._description_cache: dict[Path, str] = {}
@classmethod
def empty(cls) -> "SourceCatalog":
return cls({})
@classmethod
def from_docs_root(cls, docs_root: Path) -> "SourceCatalog":
records: dict[str, list[SourceRecord]] = defaultdict(list)
for corpus in ("net_function", "tsl_base", "knowledge_base"):
root = docs_root / corpus
manifest = root / "manifest.tsv"
if not manifest.is_file():
continue
with manifest.open(encoding="utf-8", newline="") as handle:
for row in csv.DictReader(handle, delimiter="\t"):
if row.get("status") != "ok" or not row.get("title"):
continue
record = SourceRecord(
corpus=corpus,
page_id=row.get("id", ""),
title=row["title"],
page=root / row.get("path", ""),
)
records[normalize_key(record.title)].append(record)
return cls(dict(records))
def _load_description(self, record: SourceRecord) -> SourceRecord:
if record.description:
return record
if record.page not in self._description_cache:
self._description_cache[record.page] = _extract_source_description(
record.page
)
return replace(
record, description=self._description_cache[record.page]
)
def match(self, entry: EntryContext) -> list[SourceRecord]:
matches = self.records.get(normalize_key(entry.name), [])
preference = self.PREFERENCES.get(
entry.scope,
("net_function", "tsl_base", "knowledge_base"),
)
rank = {name: index for index, name in enumerate(preference)}
def page_order(record: SourceRecord):
try:
page_id = (0, int(record.page_id))
except ValueError:
page_id = (1, record.page_id)
return rank.get(record.corpus, 99), page_id
return sorted(matches, key=page_order)
def describe(self, records: list[SourceRecord]) -> list[SourceRecord]:
return [self._load_description(record) for record in records]
def _starts_with_verb(value: str) -> bool:
return value.startswith(LEADING_VERBS) or bool(
ACTION_CLAUSE_RE.match(value)
)
def _choose_source_description(
current: str, sources: list[SourceRecord]
) -> str:
candidates = [
safe_source_description(record.description) for record in sources
]
candidates = [value for value in candidates if value]
if not candidates:
return current
if len(sources) == 1 and current in GENERIC_SUMMARIES:
candidate = candidates[0]
if candidate not in GENERIC_SUMMARIES and len(candidate) > len(current):
return candidate
if PROHIBITED_PROCESS_RE.search(current):
return candidates[0]
return current
def _property_description(value: str, access: str) -> str:
if _starts_with_verb(value):
return value
if (
len(value) > 30
or any(mark in value for mark in "。;,")
or value.startswith(("功能同", "只读", "用于"))
):
return value
normalized = normalize_key(access).replace(" ", "")
if value.startswith("是否"):
return (
f"控制{value}"
if "write" in normalized
else f"指示{value}"
)
if "read" in normalized and "write" in normalized:
return f"获取或设置{value}"
if "write" in normalized:
return f"设置{value}"
return f"获取{value}"
def _name_intent(name: str) -> str:
key = normalize_key(name)
rules = (
(("is", "if", "has", "can", "check", "valid"), "判断"),
(("get", "find", "query", "search"), "获取"),
(("read", "load"), "读取"),
(("write", "save", "export"), "写入"),
(("set",), "设置"),
(("create", "make", "new"), "创建"),
(("delete", "remove", "drop"), "删除"),
(("clear", "reset"), "清除"),
(("add", "append", "insert"), "添加"),
(("parse", "decode"), "解析"),
(("encode", "convert", "to"), "转换"),
(("format",), "格式化"),
(("calc", "compute"), "计算"),
(("open",), "打开"),
(("close",), "关闭"),
(("send", "post"), "发送"),
(("connect", "login"), "连接"),
(("list",), "列出"),
)
for prefixes, intent in rules:
if key.startswith(prefixes):
return intent
return ""
def improve_description(
entry: EntryContext, sources: list[SourceRecord]
) -> str:
current = strip_terminal_punctuation(entry.summary)
current = _choose_source_description(current, sources)
current = strip_terminal_punctuation(current)
if not current:
return entry.summary
if entry.kind == "class":
replacement = CLASS_DESCRIPTIONS.get(normalize_key(entry.name))
if replacement and re.fullmatch(
rf"{re.escape(entry.name)}\s*内置对象", current, re.IGNORECASE
):
return replacement
if entry.kind == "property":
return _property_description(current, entry.access)
key = normalize_key(entry.name)
generic_override = GENERIC_DESCRIPTION_OVERRIDES.get(key)
if generic_override and current.endswith("相关函数"):
return generic_override
awkward_override = AWKWARD_DESCRIPTION_OVERRIDES.get(key)
if awkward_override and current.startswith(
("返回根据", "返回将", "返回:", "返回:")
):
return awkward_override
current = re.sub(
r"^(?:该|本)函数(?:主要(?:是)?)?\s*", "", current
)
current = re.sub(r"^功能[:]\s*", "", current)
if current.startswith("是否"):
current = f"判断{current}"
for old, new in (
("读出", "读取"),
("取出", "获取"),
("得到", "获取"),
("获得", "获取"),
("取得", "获取"),
("新建", "创建"),
):
if current.startswith(old):
current = new + current[len(old):]
break
if current.startswith("读") and not current.startswith("读取"):
current = "读取" + current[1:]
if current.startswith("写") and not current.startswith("写入"):
current = "写入" + current[1:]
current = current.replace("读出", "读取")
current = current.replace("装载内容", "加载内容")
related_function = RELATED_FUNCTION_RE.fullmatch(current)
if related_function:
return f"返回 {related_function.group(1)} 对应的数据"
formula = FORMULA_RE.match(current)
page_key = normalize_key(entry.page)
if (
formula
and ("/financial/" in page_key or "/financial_report/" in page_key)
and not _starts_with_verb(current)
):
left, right = formula.groups()
return f"计算{left.strip()},公式为{right.strip()}"
if _starts_with_verb(current):
return current
if "/financial/" in page_key or "/financial_report/" in page_key:
if (
re.match(r"^\d+(?:\s|[..、)])", current)
or COMPARISON_RE.search(current)
):
return current
return f"返回{current}"
intent = _name_intent(entry.name)
if intent and len(current) <= 28 and not any(
verb in current[:16] for verb in LEADING_VERBS
):
return f"{intent}{current}"
return current
DOMAIN_RULES = (
(r"数组|列表|(?:^|[^a-z])(?:array|fmarray|list)(?:$|[^a-z])", ("数组", "列表", "array", "list")),
(r"矩阵|(?:^|[^a-z])matrix(?:$|[^a-z])|\bmt_", ("矩阵", "matrix")),
(r"字符串|文本|(?:^|[^a-z])(?:string|char|text)(?:$|[^a-z])", ("字符串", "文本", "string", "text")),
(r"日期|时间|(?:^|[^a-z])(?:datetime|date|time)(?:$|[^a-z])", ("日期时间", "日期", "时间", "datetime")),
(r"文件|目录|路径|(?:^|[^a-z])(?:file|folder|directory|path)(?:$|[^a-z])", ("文件", "目录", "路径", "file")),
(r"网络|(?:^|[^a-z])(?:http|https|cgi|cookie|url)(?:$|[^a-z])", ("网络", "HTTP", "请求", "network")),
(r"缓存|(?:^|[^a-z])cache(?:$|[^a-z])", ("缓存", "缓存管理", "cache")),
(r"数据库|(?:^|[^a-z])(?:sql|dbf|ini)(?:$|[^a-z])", ("数据库", "SQL", "database")),
(r"进程|线程|(?:^|[^a-z])(?:process|thread|pipe)(?:$|[^a-z])", ("进程", "线程", "process")),
(r"颜色|(?:^|[^a-z])(?:color|rgb|cmyk)(?:$|[^a-z])", ("颜色", "RGB", "color")),
(r"图形|图表|(?:^|[^a-z])(?:graph|chart)(?:$|[^a-z])", ("图形", "图表", "graph")),
(r"统计|概率|分布|(?:^|[^a-z])(?:statistics|cdf|pdf)(?:$|[^a-z])", ("统计", "概率", "statistics")),
(r"优化|线性规划|(?:^|[^a-z])optimization(?:$|[^a-z])", ("优化", "求解", "optimization")),
(r"数值|数学|(?:^|[^a-z])(?:numeric|math)(?:$|[^a-z])", ("数学", "数值", "numeric")),
(r"类型转换|(?:^|[^a-z])(?:convert|conversion)(?:$|[^a-z])", ("类型转换", "转换", "conversion")),
(r"对象|(?:^|[^a-z])(?:object|class)(?:$|[^a-z])", ("对象", "实例", "object")),
(r"(?:^|[^a-z])(?:com|ole)(?:$|[^a-z])|activex", ("COM", "OLE", "自动化")),
(r"(?:^|[^a-z])ftp(?:$|[^a-z])", ("FTP", "文件传输")),
(r"邮件|(?:^|[^a-z])(?:smtp|pop3|imap|mail)(?:$|[^a-z])", ("邮件", "SMTP", "POP3", "IMAP")),
(r"财务|金融|证券|股票|基金|债券|期货|期权", ("金融",)),
(r"报表|报告期|report", ("报表", "报告期")),
)
SPECIAL_TAG_RULES = (
(r"去重|删除重复|dropduplicate|dedup", ("去重", "删除重复", "deduplicate")),
(r"哈希索引|哈希表", ("哈希索引", "哈希表", "hash")),
(
r"摘要|(?:^|[^a-z0-9_])(?:crc32|md5|sha1|sha224|sha256|sha384|sha512|sm3|digest|hash)(?:$|[^a-z0-9_])",
("摘要", "哈希", "散列", "digest", "hash"),
),
(r"排序|sort", ("排序", "sort")),
(r"过滤|筛选|filter", ("过滤", "筛选", "filter")),
(r"查找|搜索|find|search|lookup", ("查找", "搜索", "lookup")),
)
INTENT_TAGS = {
"返回": ("返回", "获取"),
"获取": ("获取", "查询"),
"读取": ("读取", "获取"),
"写入": ("写入", "保存"),
"设置": ("设置", "修改"),
"创建": ("创建", "生成"),
"删除": ("删除", "移除"),
"清除": ("清除", "重置"),
"添加": ("添加", "追加"),
"查找": ("查找", "搜索"),
"判断": ("判断", "检查"),
"计算": ("计算", "求解"),
"转换": ("转换", "编码"),
"解析": ("解析", "parse"),
"格式化": ("格式化", "format"),
"打开": ("打开", "open"),
"关闭": ("关闭", "close"),
"发送": ("发送", "提交"),
"连接": ("连接", "登录"),
"列出": ("列出", "列表"),
"输出": ("输出", "打印"),
}
def _taxonomy_tags(title: str) -> list[str]:
result = []
for raw in re.split(r"\s*/\s*|\s+-\s+", title):
value = raw.strip(" #")
value = re.sub(r"\([^)]*\)|[^]*", "", value).strip()
for suffix in ("相关函数", "及其实现", "相关"):
if value.endswith(suffix):
value = value[: -len(suffix)].rstrip()
if not value or normalize_key(value) in GENERIC_TAGS:
continue
if len(value) > 18:
continue
chinese = "".join(re.findall(r"[\u3400-\u9fff]+", value))
latin = re.sub(r"[\u3400-\u9fff]+", " ", value).strip()
if latin and normalize_key(latin) not in GENERIC_TAGS:
result.extend(
re.findall(r"[A-Za-z][A-Za-z0-9_+.-]*", latin)
)
if chinese:
result.append(chinese)
return result
def _description_intent(summary: str) -> str:
value = strip_terminal_punctuation(summary)
replacements = {
"得到": "获取",
"获得": "获取",
"取得": "获取",
"新建": "创建",
}
for source, target in replacements.items():
if value.startswith(source):
return target
for intent in INTENT_TAGS:
if value.startswith(intent):
return intent
for intent in ("读取", "写入", "保存", "计算", "判断", "转换", "解析"):
if intent in value[:16]:
return "写入" if intent == "保存" else intent
return ""
def derive_tags(entry: EntryContext, page: PageContext) -> list[str]:
tags: list[str] = []
seen: set[str] = set()
def add(value: str) -> None:
value = clean_text(value).strip()
key = normalize_key(value)
if (
not value
or key in seen
or key in GENERIC_TAGS
or len(tags) >= 12
):
return
seen.add(key)
tags.append(value)
for tag in entry.tags:
add(tag)
for tag in _taxonomy_tags(page.title):
add(tag)
haystack = " ".join(
(entry.name, entry.signature, entry.summary, page.title, page.path)
)
domain_haystack = " ".join((page.title, page.path))
for pattern, aliases in SPECIAL_TAG_RULES:
if re.search(pattern, haystack, re.IGNORECASE):
for alias in aliases:
add(alias)
summary = strip_terminal_punctuation(entry.summary)
intent = _description_intent(summary)
if not intent and not _starts_with_verb(summary):
is_market_price = (
normalize_key(entry.name) in {"open", "close"}
and "盘价" in summary
)
if not is_market_price:
intent = _name_intent(entry.name)
for alias in INTENT_TAGS.get(intent, ()):
add(alias)
for pattern, aliases in DOMAIN_RULES:
if re.search(pattern, domain_haystack, re.IGNORECASE):
for alias in aliases:
add(alias)
for acronym in ACRONYM_RE.findall(entry.summary):
add(acronym)
return tags
def _scope_for_page(page: str) -> str:
parts = Path(page).parts
return parts[0] if len(parts) > 1 else Path(page).stem
def _page_title(lines: list[str], page: str) -> PageContext:
for line in lines:
match = H1_RE.match(line)
if match:
return PageContext(match.group(1), page)
return PageContext(Path(page).stem, page)
def _entry_metadata(lines: list[str], start: int, end: int):
declaration_seen = False
description_index = None
tag_index = None
access = ""
for index in range(start + 1, end):
text = lines[index].strip()
if not text:
continue
if not declaration_seen:
if DECLARATION_LINE_RE.fullmatch(text):
declaration_seen = True
continue
tag_match = TAGS_RE.fullmatch(text)
if tag_match:
tag_index = index
continue
access_match = ACCESS_RE.fullmatch(text)
if access_match:
access = access_match.group(1)
continue
if description_index is None:
if text.startswith(("|", "#", "返回:", "类型:", "可见性:", "值:")):
continue
description_index = index
tags = ()
if tag_index is not None:
tags = tuple(TAGS_RE.fullmatch(lines[tag_index].strip()).group(1).split())
return description_index, tag_index, access, tags
def enrich_markdown(
text: str, page: str, source_catalog: SourceCatalog
) -> tuple[str, Audit]:
had_final_newline = text.endswith("\n")
lines = text.splitlines()
page_context = _page_title(lines, page)
entries = [entry for entry in iter_api_entries(lines) if entry.heading.valid]
audit = Audit(entries=len(entries))
root_owner = ""
owners: dict[int, str] = {}
for entry in entries:
if entry.heading.level == 2:
root_owner = entry.heading.name if entry.heading.kind in {"class", "unit"} else ""
owners[entry.start] = root_owner if entry.heading.level > 2 else ""
for api in reversed(entries):
description_index, tag_index, access, existing_tags = _entry_metadata(
lines, api.start, api.end
)
if description_index is None:
continue
old_description = lines[description_index].strip()
entry = EntryContext(
name=api.heading.name,
signature=api.heading.signature,
kind=api.heading.kind,
scope=_scope_for_page(page),
page=page,
summary=old_description,
access=access,
owner=owners.get(api.start, ""),
tags=existing_tags,
)
sources = source_catalog.match(entry)
if sources:
audit.source_matches += 1
if len(sources) > 1:
audit.ambiguous_sources += 1
evidence = sources
normalized_summary = strip_terminal_punctuation(old_description)
if (
normalized_summary in GENERIC_SUMMARIES
or PROHIBITED_PROCESS_RE.search(normalized_summary)
):
evidence = source_catalog.describe(sources)
new_description = improve_description(entry, evidence)
entry_for_tags = replace(entry, summary=new_description)
new_tags = tuple(derive_tags(entry_for_tags, page_context))
if new_description != old_description:
lines[description_index] = new_description
audit.changed_descriptions += 1
if new_tags != existing_tags:
rendered = f"<!-- tags: {' '.join(new_tags)} -->"
if tag_index is not None:
lines[tag_index] = rendered
else:
insert_at = description_index + 1
if insert_at < len(lines) and lines[insert_at].strip() == "":
insert_at += 1
lines[insert_at:insert_at] = [rendered, ""]
else:
lines[insert_at:insert_at] = ["", rendered, ""]
audit.changed_tags += 1
if new_description != old_description or new_tags != existing_tags:
audit.changes.append(
AuditChange(
page=page,
name=api.heading.name,
kind=api.heading.kind,
old_description=old_description,
new_description=new_description,
old_tags=existing_tags,
new_tags=new_tags,
sources=tuple(
f"{record.corpus}:{record.page_id}" for record in sources
),
)
)
result = "\n".join(lines)
if had_final_newline:
result += "\n"
return result, audit
def _write_report(path: Path, audit: Audit) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle, delimiter="\t", lineterminator="\n")
writer.writerow(
(
"page",
"name",
"kind",
"old_description",
"new_description",
"old_tags",
"new_tags",
"sources",
)
)
for item in audit.changes:
writer.writerow(
(
item.page,
item.name,
item.kind,
item.old_description,
item.new_description,
" ".join(item.old_tags),
" ".join(item.new_tags),
" ".join(item.sources),
)
)
def _selected(page: str, scopes: set[str], prefixes: tuple[str, ...]) -> bool:
if scopes and _scope_for_page(page) not in scopes:
return False
return not prefixes or page.startswith(prefixes)
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--audit", action="store_true", help="report changes only")
mode.add_argument("--apply", action="store_true", help="write proposed changes")
parser.add_argument("--root", type=Path, required=True, help="codegen Markdown root")
parser.add_argument(
"--docs-root", type=Path, required=True, help="cached tmp/docs root"
)
parser.add_argument("--report", type=Path, help="optional TSV audit report")
parser.add_argument("--scope", action="append", default=[])
parser.add_argument("--prefix", action="append", default=[])
args = parser.parse_args(argv)
catalog = SourceCatalog.from_docs_root(args.docs_root)
aggregate = Audit()
changed_files = 0
for md in sorted(args.root.rglob("*.md")):
page = md.relative_to(args.root).as_posix()
if not _selected(page, set(args.scope), tuple(args.prefix)):
continue
before_stat = md.stat()
original = md.read_text(encoding="utf-8")
enriched, audit = enrich_markdown(original, page, catalog)
aggregate.merge(audit)
if enriched == original:
continue
changed_files += 1
if args.apply:
after_stat = md.stat()
if (
after_stat.st_mtime_ns != before_stat.st_mtime_ns
or after_stat.st_size != before_stat.st_size
):
raise RuntimeError(f"target changed while processing: {md}")
md.write_text(enriched, encoding="utf-8", newline="\n")
if args.report:
_write_report(args.report, aggregate)
print(
"entries={entries} source_matches={source_matches} "
"ambiguous_sources={ambiguous_sources} changed_files={changed_files} "
"changed_descriptions={changed_descriptions} changed_tags={changed_tags}".format(
entries=aggregate.entries,
source_matches=aggregate.source_matches,
ambiguous_sources=aggregate.ambiguous_sources,
changed_files=changed_files,
changed_descriptions=aggregate.changed_descriptions,
changed_tags=aggregate.changed_tags,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())