diff --git a/doc/12-uv换源实现说明.md b/doc/12-uv换源实现说明.md new file mode 100644 index 00000000..a310d2cb --- /dev/null +++ b/doc/12-uv换源实现说明.md @@ -0,0 +1,96 @@ + + +# uv 换源实现说明 + +`chsrc set uv` 同时配置两类下载地址: + +1. Python 包索引,即 `uv.toml` 的 `[[index]]` 或 `pyproject.toml` 的 `[[tool.uv.index]]` +2. uv 管理的 CPython 下载地址,即 `python-install-mirror` + +
+ + +## 配置文件选择 + +项目作用域只修改当前目录,不向父目录查找并修改配置: + +1. 当前目录存在 `uv.toml` 时修改 `uv.toml` +2. 否则,当前目录存在 `pyproject.toml` 时修改其中的 `[tool.uv]` +3. 两者都不存在时创建 `uv.toml` + +这个顺序与 uv 的行为一致:同一目录同时存在 `uv.toml` 与 +`pyproject.toml` 的 `[tool.uv]` 时,uv 优先使用 `uv.toml` 中的对应字段。 + +用户作用域使用 uv 的用户配置目录:Windows 为 `%APPDATA%\uv\uv.toml`, +Linux 和 macOS 为 `~/.config/uv/uv.toml`。 + +
+ + +## Python 包索引 + +索引 URL 必须指向兼容 Python Simple Repository API 的地址,通常以 +`/simple` 结尾;uv 不要求 URL 必须以 `/` 结尾。 + +`default = true` 表示该 index 替代 uv 内置的 PyPI,并且始终具有最低 +index 优先级。它不表示“文本中的第一个”或“最高优先级”。配置中存在多个 +index 时,chsrc 优先更新 `default = true` 的 index;没有 default index +时才更新第一个 index。 + +默认的 `first-index` 策略会在第一个包含目标包的 index 停止搜索。私有源 +与公共镜像并存时,不应把它理解成跨所有 index 选择全局最新版本。 + +
+ + +## Python 下载镜像 + +`python-install-mirror` 是 +`https://github.com/astral-sh/python-build-standalone/releases/download` +的替换前缀。配置值必须是镜像侧对应的 `releases/download` 基址,不能是 +仓库主页、某个具体构建日期或某个归档文件。uv 会在其后追加构建日期和 +归档文件名。 + +官方示例使用不带尾部 `/` 的基址,chsrc 内置源也遵循该形式。 + +
+ + +## reset 与备份 + +`chsrc set uv` 修改已有配置前会创建 `.bak` 备份。 + +`chsrc reset uv` 把 chsrc 管理的 index 与 `python-install-mirror` 写回 +默认上游:index 恢复为 `https://pypi.org/simple`, +`python-install-mirror` 恢复为 +`https://github.com/astral-sh/python-build-standalone/releases/download`。 +其他用户配置原样保留,不依赖 `.bak` 恢复旧 URL。 + +`default = true` 的 index(不存在时取第一个)被视为 PyPI 替代源, +reset 只修改这一处;私有 index 不受影响。 + +
+ + +## 实现边界 + +配置改写器只解析 chsrc 使用的 TOML 子集,但支持常见的行首缩进、CRLF、 +行尾注释、多个 index,以及位于 `[tool.uv.sources]` 等子表之后的 +`[[tool.uv.index]]`。改写会保留原有缩进与行尾风格(CRLF/LF)。多行字符串 +和完整 TOML 转义语义不属于该轻量改写器的支持范围。 + +参考: + +- [uv index 设置](https://docs.astral.sh/uv/reference/settings/#index) +- [uv python-install-mirror 设置](https://docs.astral.sh/uv/reference/settings/#python-install-mirror) +- [uv 配置文件](https://docs.astral.sh/uv/concepts/configuration-files/) +- [uv package indexes](https://docs.astral.sh/uv/concepts/indexes/) + +
diff --git a/src/recipe/lang/Python/uv-helper.c b/src/recipe/lang/Python/uv-helper.c index 592894cd..4a6b84de 100644 --- a/src/recipe/lang/Python/uv-helper.c +++ b/src/recipe/lang/Python/uv-helper.c @@ -8,14 +8,16 @@ * Last Modified : <2026-08-02> * * - * uv.toml 配置字符串改写工具 (由 uv.c #include, 可被 test 单独 #include) + * uv 配置 (uv.toml / pyproject.toml) 字符串改写工具 + * (由 uv.c #include, 可被 test 单独 #include) * * 仅处理 chsrc 关心的 uv 配置子集, 不是完整 TOML 解析器: - * - [[index]] 数组表: 替换/清理其 url、default 行 (保留段头与其他键) - * - 顶层 python-install-mirror 键: 替换/删除 + * - uv.toml: 顶层 [[index]] 数组表、顶层 python-install-mirror 键 + * - pyproject.toml: [tool.uv] 表内的 [[tool.uv.index]] 数组表、 + * python-install-mirror 键 * * 刻意不处理: 多行字符串、数组/内联表值、字符串转义 —— - * 因为所有改写都按整行“替换/删除”进行, 无需解析值本身。 + * 因为所有改写都按"整行替换/删除"进行, 无需解析值本身。 * 已知限制: 多行字符串 (""") 内若出现行首 '[' 会被误认为段头, 罕见可忽略。 * * 所有函数均为 static, 依赖调用方所在翻译单元提供 stdlib/string.h。 @@ -39,15 +41,25 @@ uvh_key_prefix (const char *p, const char *key) return c == '=' || c == ' ' || c == '\t' || c == '\n' || c == '\0'; } +static const char * +uvh_skip_indent (const char *p) +{ + while (*p == ' ' || *p == '\t') p++; + return p; +} + /** - * 判断行首是否为 [[index]] 数组表头 (允许行尾注释, 如 "[[index]] # note")。 + * 判断行首 (调用方必须保证 p 位于行首) 是否为指定表头文本。 + * 允许缩进、CRLF 和行尾注释。 */ static bool -uvh_is_index_header (const char *p) +uvh_header_match (const char *p, const char *header) { - if (strncmp (p, "[[index]]", 9) != 0) return false; - char c = p[9]; - return c == '\n' || c == '\0' || c == ' ' || c == '\t'; + p = uvh_skip_indent (p); + size_t hl = strlen (header); + if (strncmp (p, header, hl) != 0) return false; + char c = p[hl]; + return c == '\n' || c == '\r' || c == '\0' || c == ' ' || c == '\t' || c == '#'; } /* 跳过当前行 (含换行符), 返回下一行的行首 */ @@ -60,7 +72,7 @@ uvh_next_line (const char *p) } /** - * 从 start (段内第一行) 开始找下一个段头行 (行首 '[') 的位置。 + * 从 start 开始找下一个段头行 (行首 '[') 的位置。 * 返回 NULL 表示直到文件结尾都没有新段头。 */ static const char * @@ -69,237 +81,427 @@ uvh_find_section_end (const char *start) const char *p = start; while (*p) { - if (*p == '[') return p; + if (*uvh_skip_indent (p) == '[') return p; p = uvh_next_line (p); } return NULL; } +/** + * 全文件查找第一个行首匹配 header 的表头位置; 未找到返回 NULL。 + */ +static const char * +uvh_find_table (const char *content, const char *header) +{ + const char *p = content; + while (*p) + { + const char *line = uvh_skip_indent (p); + if (*line == '[' && uvh_header_match (line, header)) return p; + p = uvh_next_line (p); + } + return NULL; +} /** - * 在 content 中找到第一个 [[index]] 段, 替换其 url = "..." 行 (或单引号值)。 - * 文件中没有 [[index]] 段时, 追加整个段到末尾。 + * 在 [first, end) 行区间内查找以 key 开头的键行; end 为 NULL 时到文件尾。 + */ +static const char * +uvh_find_key_in_section (const char *first, const char *end, const char *key) +{ + for (const char *s = first; *s && (!end || s < end); s = uvh_next_line (s)) + { + if (uvh_key_prefix (uvh_skip_indent (s), key)) return s; + } + return NULL; +} + +/** + * 从 "key = \"value\"" 或 "key = 'value'" 行提取字符串值。 + * @return malloc 的 caller-free 字符串; 值不是字符串时返回 NULL。 + */ +static char * +uvh_extract_string_value (const char *line) +{ + const char *eq = strchr (uvh_skip_indent (line), '='); + if (!eq) return NULL; + const char *v = eq + 1; + while (*v == ' ' || *v == '\t') v++; + if (*v != '"' && *v != '\'') return NULL; + char quote = *v; + v++; + const char *val = v; + while (*v && *v != quote && *v != '\n') v++; + size_t vl = v - val; + char *ret = malloc (vl + 1); + memcpy (ret, val, vl); + ret[vl] = '\0'; + return ret; +} + +static bool +uvh_value_is_true (const char *line) +{ + const char *eq = strchr (uvh_skip_indent (line), '='); + if (!eq) return false; + const char *v = uvh_skip_indent (eq + 1); + return strncmp (v, "true", 4) == 0 + && (v[4] == '\0' || v[4] == '\r' || v[4] == '\n' || v[4] == ' ' || v[4] == '\t' || v[4] == '#'); +} + +/** + * 查找要管理的 index。多个 index 中优先选择 default = true, + * 否则选择第一个;表可以位于 [tool.uv.sources] 等子表之后。 + */ +static const char * +uvh_find_managed_index (const char *content, const char *index_header) +{ + const char *first = NULL; + for (const char *s = content; *s; s = uvh_next_line (s)) + { + if (!uvh_header_match (s, index_header)) continue; + if (!first) first = s; + + const char *body = uvh_next_line (s); + const char *end = uvh_find_section_end (body); + const char *default_line = uvh_find_key_in_section (body, end, "default"); + if (default_line && uvh_value_is_true (default_line)) return s; + } + return first; +} + +/* 文件是否使用 CRLF 换行。 */ +static bool +uvh_detect_crlf (const char *content) +{ + const char *nl = strchr (content, '\n'); + return nl && nl > content && nl[-1] == '\r'; +} + +/** + * 按 content 的换行风格转换 str 中的 LF;content 为 CRLF 时返回 CRLF 版本。 + * 仅用于追加的纯 LF 文本,不要传入已含 CRLF 的 content 本身。 * - * 只修改第一个 [[index]] 段; 段内 url/default 键的语义由调用者 (set) 决定。 + * @return caller-free + */ +static char * +uvh_apply_eol (const char *content, const char *str) +{ + bool crlf = uvh_detect_crlf (content); + size_t n = strlen (str); + char *ret = malloc (n + (crlf ? n : 0) + 1); + size_t pos = 0; + for (const char *s = str; *s; s++) + { + if (crlf && *s == '\n') ret[pos++] = '\r'; + ret[pos++] = *s; + } + ret[pos] = '\0'; + return ret; +} + +/** + * 用 new_line_text (不含换行) 替换 content 中从 line 开始的整行, + * 保留原行缩进、行尾风格 (CRLF/LF) 与后续内容。 * * @return 新内容 (caller-free) */ static char * -replace_pypi_index_url (const char *content, const char *url) +uvh_replace_line (const char *content, const char *line, const char *new_line_text) { - /* 找第一个行首 [[index]] 段头 (跳过注释等非段头匹配) */ - const char *p = content; - const char *index_header = NULL; - while (*p) - { - if (*p == '[' && uvh_is_index_header (p)) { index_header = p; break; } - p = uvh_next_line (p); - } - - /* 文件中尚无 [[index]] 段, 追加到末尾 */ - if (!index_header) - { - size_t len = strlen (content) + strlen (url) + 96; - char *ret = calloc (len, 1); - size_t clen = strlen (content); - if (clen > 0 && content[clen - 1] == '\n') - snprintf (ret, len, "%s[[index]]\nurl = \"%s\"\ndefault = true\n", content, url); - else if (clen > 0) - snprintf (ret, len, "%s\n[[index]]\nurl = \"%s\"\ndefault = true\n", content, url); - else - snprintf (ret, len, "[[index]]\nurl = \"%s\"\ndefault = true\n", url); - return ret; - } - - /* 段内第一行与段边界 (下一个段头或文件尾) */ - const char *first = uvh_next_line (index_header); - const char *end = uvh_find_section_end (first); - - /* 在当前 [[index]] 段内找 url 键行 */ - const char *url_line = NULL; - for (const char *s = first; *s && (!end || s < end); s = uvh_next_line (s)) - { - if (uvh_key_prefix (s, "url")) { url_line = s; break; } - } - - /* 段内没有 url 键: 在段内第一行之前插入 url 行 (及缺失的 default 行) */ - if (!url_line) - { - bool has_default = false; - for (const char *s = first; *s && (!end || s < end); s = uvh_next_line (s)) - { - if (uvh_key_prefix (s, "default")) { has_default = true; break; } - } - - size_t len = strlen (content) + strlen (url) + 64; - char *ret = calloc (len, 1); - size_t pos = 0; - pos += snprintf (ret + pos, len - pos, "%.*s", (int)(first - content), content); - pos += snprintf (ret + pos, len - pos, "url = \"%s\"\n", url); - if (!has_default) - pos += snprintf (ret + pos, len - pos, "default = true\n"); - strcpy (ret + pos, first); - return ret; - } - - /* 替换 url 行, 保留该行原有的换行符与后续内容 */ - const char *line_end = strchr (url_line, '\n'); + const char *line_end = strchr (line, '\n'); if (!line_end) line_end = content + strlen (content); - size_t len = strlen (content) + strlen (url) + 32; + /* 原行为 CRLF 时,新行同样使用 CRLF */ + bool crlf = (*line_end == '\n' && line_end > line && line_end[-1] == '\r'); + + /* 复用原行缩进 */ + const char *indent_end = uvh_skip_indent (line); + size_t ind = indent_end - line; + + size_t len = strlen (content) + ind + strlen (new_line_text) + 8; char *ret = calloc (len, 1); size_t pos = 0; - pos += snprintf (ret + pos, len - pos, "%.*s", (int)(url_line - content), content); - pos += snprintf (ret + pos, len - pos, "url = \"%s\"", url); + pos += snprintf (ret + pos, len - pos, "%.*s", (int)(line - content), content); + pos += snprintf (ret + pos, len - pos, "%.*s", (int)ind, line); + pos += snprintf (ret + pos, len - pos, "%s", new_line_text); + if (crlf) ret[pos++] = '\r'; strcpy (ret + pos, line_end); return ret; } - /** - * 替换顶层 python-install-mirror 键的值; 顶层无该键时, 追加到顶层区域末尾 - * (第一个段头之前), 保证新键仍是顶层键而非被归入某个表。 - * - * 段内 (非顶层) 的同名键不受影响 —— chsrc 只管理顶层键。 + * 在 insert_at 位置之前插入 insert_text (以 '\n' 结尾)。 + * 内容为 CRLF 时,插入文本与分隔换行同样转换为 CRLF。 * * @return 新内容 (caller-free) */ static char * -replace_python_install_mirror (const char *content, const char *url) +uvh_insert_before (const char *content, const char *insert_at, const char *insert_text) { - /* 只扫描顶层区域 (第一个段头之前) */ - const char *p = content; - const char *old_line = NULL; - const char *first_section = NULL; - while (*p) - { - if (*p == '[') - { - first_section = p; - break; - } - if (uvh_key_prefix (p, "python-install-mirror")) old_line = p; - p = uvh_next_line (p); - } - - size_t base = strlen (content) + strlen (url) + 64; - - /* 顶层已有旧行: 就地替换 */ - if (old_line) - { - const char *line_end = strchr (old_line, '\n'); - if (!line_end) line_end = content + strlen (content); - - char *ret = calloc (base, 1); - size_t pos = 0; - pos += snprintf (ret + pos, base - pos, "%.*s", (int)(old_line - content), content); - pos += snprintf (ret + pos, base - pos, "python-install-mirror = \"%s\"", url); - strcpy (ret + pos, line_end); - return ret; - } - - /* 顶层无旧行: 追加到顶层区域末尾 (无段头则文件末尾) */ - const char *insert_at = first_section ? first_section : content + strlen (content); size_t head = insert_at - content; - char *ret = calloc (base, 1); + bool need_sep = (head > 0 && content[head - 1] != '\n'); + bool crlf = need_sep ? uvh_detect_crlf (content) + : (head >= 2 && content[head - 1] == '\n' && content[head - 2] == '\r'); + + size_t text_len = strlen (insert_text); + size_t extra = (crlf ? text_len : 0) + (need_sep ? 1 : 0); + size_t len = strlen (content) + text_len + extra + 16; + char *ret = calloc (len, 1); size_t pos = 0; - pos += snprintf (ret + pos, base - pos, "%.*s", (int)head, content); - if (head > 0 && content[head - 1] != '\n') ret[pos++] = '\n'; - pos += snprintf (ret + pos, base - pos, "python-install-mirror = \"%s\"\n", url); + pos += snprintf (ret + pos, len - pos, "%.*s", (int)head, content); + if (need_sep) + { + if (crlf) ret[pos++] = '\r'; + ret[pos++] = '\n'; + } + for (const char *t = insert_text; *t; t++) + { + if (crlf && *t == '\n') ret[pos++] = '\r'; + ret[pos++] = *t; + } strcpy (ret + pos, content + head); return ret; } /** - * reset 专用: 删除每个 [[index]] 段内的 url/default 行 (保留段头与 - * name 等其余字段), 删除顶层 python-install-mirror 行, 其余内容原样保留。 + * 替换/创建 index 数组表 (如 [[index]] 或 [[tool.uv.index]]) 的 url 值。 * - * 段边界严格限定: 其他表 (如 [python]) 内同名的 url/default 键不受影响。 + * @param content 原始内容 + * @param url 新的索引 URL + * @param index_header 段头文本, 如 "[[index]]" 或 "[[tool.uv.index]]" + * @param parent_table 父表头, 如 NULL (顶层) 或 "[tool.uv]"; + * 父表不存在时会在文件末尾创建父表并写入 index 段 * * @return 新内容 (caller-free) */ static char * -cleanup_config_for_reset (const char *content) +replace_index_url (const char *content, const char *url, const char *index_header, const char *parent_table) { - size_t est = strlen (content) + 128; - char *ret = calloc (est, 1); - size_t pos = 0; - - bool in_table = false; /* 是否已进入任何表 (用于限定 python-install-mirror 仅顶层) */ - bool in_index = false; /* 当前是否处于 [[index]] 段 (用于限定 url/default) */ - - const char *p = content; - while (*p) + /* default index 才是 uv 的 PyPI 替代源;不存在 default 时回退到第一个。 */ + const char *ih = uvh_find_managed_index (content, index_header); + const char *boundary = content + strlen (content); + if (!ih && parent_table) { - bool skip = false; - - if (*p == '[') + const char *table = uvh_find_table (content, parent_table); + if (!table) { - in_table = true; - in_index = uvh_is_index_header (p); - } - else if (in_index && (uvh_key_prefix (p, "url") || uvh_key_prefix (p, "default"))) - { - skip = true; - } - else if (!in_table && uvh_key_prefix (p, "python-install-mirror")) - { - skip = true; - } - - if (skip) - { - while (*p && *p != '\n') p++; - if (*p == '\n') p++; - } - else - { - while (*p && *p != '\n') ret[pos++] = *p++; - if (*p == '\n') ret[pos++] = *p++; + /* 父表不存在: 在文件末尾创建父表并在其中创建 index 段, + * 换行风格与 content 保持一致 */ + size_t clen = strlen (content); + const char *sep = (clen > 0) ? "\n" : ""; + size_t seg_len = strlen (parent_table) + strlen (index_header) + strlen (url) + 48; + char *seg = calloc (seg_len, 1); + snprintf (seg, seg_len, "%s\n%s\nurl = \"%s\"\ndefault = true\n", + parent_table, index_header, url); + char *seg_eol = uvh_apply_eol (content, seg); + free (seg); + char *sep_eol = uvh_apply_eol (content, sep); + size_t len = clen + strlen (sep_eol) + strlen (seg_eol) + 1; + char *ret = calloc (len, 1); + snprintf (ret, len, "%s%s%s", content, sep_eol, seg_eol); + free (sep_eol); + free (seg_eol); + return ret; } } - ret[pos] = '\0'; + /* 作用域内无 index 段: 追加到边界之前 */ + if (!ih) + { + size_t seglen = strlen (index_header) + strlen (url) + 48; + char *seg = malloc (seglen); + snprintf (seg, seglen, "%s\nurl = \"%s\"\ndefault = true\n", index_header, url); + char *ret = uvh_insert_before (content, boundary, seg); + free (seg); + return ret; + } + + /* 段内第一行与段边界 (下一个段头或文件尾) */ + const char *first = uvh_next_line (ih); + const char *end = uvh_find_section_end (first); + const char *url_line = uvh_find_key_in_section (first, end, "url"); + + /* 段内没有 url 键: 在段内第一行之前插入 url (及缺失的 default) */ + if (!url_line) + { + bool has_default = uvh_find_key_in_section (first, end, "default") != NULL; + size_t seglen = strlen (url) + (has_default ? 24 : 48); + char *seg = malloc (seglen); + size_t spos = 0; + spos += snprintf (seg + spos, seglen - spos, "url = \"%s\"\n", url); + if (!has_default) + spos += snprintf (seg + spos, seglen - spos, "default = true\n"); + char *ret = uvh_insert_before (content, first, seg); + free (seg); + return ret; + } + + /* 替换 url 行 */ + size_t newlen = strlen (url) + 32; + char *new_line = malloc (newlen); + snprintf (new_line, newlen, "url = \"%s\"", url); + char *ret = uvh_replace_line (content, url_line, new_line); + free (new_line); return ret; } /** - * 提取顶层键的字符串值 (双引号或单引号), 返回 malloc 的 caller-free 字符串。 - * 键不存在、值不是字符串、或键位于段内 (非顶层) 时返回 NULL。 + * 替换/创建顶层键或指定表内键 (如 python-install-mirror) 的字符串值。 + * + * @param content 原始内容 + * @param key 键名 + * @param url 新值 + * @param parent_table 父表头, 如 NULL (顶层) 或 "[tool.uv]"; + * 父表不存在时会在文件末尾创建父表并写入键 + * + * @return 新内容 (caller-free) */ static char * -uvh_get_top_level_value (const char *content, const char *key) +replace_key_value (const char *content, const char *key, const char *url, const char *parent_table) { - const char *p = content; - bool in_table = false; - while (*p) + const char *old_line = NULL; + const char *insert_at = NULL; + + if (parent_table) { - if (*p == '[') + const char *table = uvh_find_table (content, parent_table); + if (!table) { - in_table = true; - } - else if (!in_table && uvh_key_prefix (p, key)) - { - const char *v = p + strlen (key); - while (*v == ' ' || *v == '\t') v++; - if (*v != '=') return NULL; - v++; - while (*v == ' ' || *v == '\t') v++; - if (*v != '"' && *v != '\'') return NULL; - char quote = *v; - v++; - const char *val = v; - while (*v && *v != quote && *v != '\n') v++; - size_t vl = v - val; - char *ret = malloc (vl + 1); - memcpy (ret, val, vl); - ret[vl] = '\0'; + /* 父表不存在: 在文件末尾创建父表并写入键, + * 换行风格与 content 保持一致 */ + size_t clen = strlen (content); + const char *sep = (clen > 0) ? "\n" : ""; + size_t seg_len = strlen (parent_table) + strlen (key) + strlen (url) + 16; + char *seg = calloc (seg_len, 1); + snprintf (seg, seg_len, "%s\n%s = \"%s\"\n", parent_table, key, url); + char *seg_eol = uvh_apply_eol (content, seg); + free (seg); + char *sep_eol = uvh_apply_eol (content, sep); + size_t len = clen + strlen (sep_eol) + strlen (seg_eol) + 1; + char *ret = calloc (len, 1); + snprintf (ret, len, "%s%s%s", content, sep_eol, seg_eol); + free (sep_eol); + free (seg_eol); return ret; } - p = uvh_next_line (p); + + const char *first = uvh_next_line (table); + const char *key_zone_end = NULL; + for (const char *s = first; *s; s = uvh_next_line (s)) + { + const char *line = uvh_skip_indent (s); + if (*line == '[') + { + key_zone_end = s; + break; + } + } + old_line = uvh_find_key_in_section (first, key_zone_end, key); + insert_at = key_zone_end ? key_zone_end : content + strlen (content); + } + else + { + /* 顶层键区: 到第一个段头为止 */ + const char *p = content; + while (*p) + { + if (*uvh_skip_indent (p) == '[') { insert_at = p; break; } + if (uvh_key_prefix (uvh_skip_indent (p), key)) old_line = p; + p = uvh_next_line (p); + } + if (!insert_at) insert_at = content + strlen (content); + } + + if (old_line) + { + /* 替换旧值行 */ + size_t newlen = strlen (key) + strlen (url) + 32; + char *new_line = malloc (newlen); + snprintf (new_line, newlen, "%s = \"%s\"", key, url); + char *ret = uvh_replace_line (content, old_line, new_line); + free (new_line); + return ret; + } + + /* 键不存在: 追加到作用域末尾 */ + size_t seglen = strlen (key) + strlen (url) + 32; + char *seg = malloc (seglen); + snprintf (seg, seglen, "%s = \"%s\"\n", key, url); + char *ret = uvh_insert_before (content, insert_at, seg); + free (seg); + return ret; +} + + +/** + * 在指定表 (parent_table 为 NULL 表示顶层) 内提取键的字符串值。 + * + * @return malloc 的 caller-free 字符串; 未找到返回 NULL。 + */ +static char * +uvh_get_value_in_table (const char *content, const char *key, const char *parent_table) +{ + const char *p = content; + if (parent_table) + { + const char *table = uvh_find_table (content, parent_table); + if (!table) return NULL; + p = uvh_next_line (table); + } + + const char *limit = uvh_find_section_end (p); + for (const char *s = p; *s && (!limit || s < limit); s = uvh_next_line (s)) + { + if (uvh_key_prefix (uvh_skip_indent (s), key)) return uvh_extract_string_value (s); } return NULL; } + + +/** + * 提取 chsrc 管理的 index URL:优先 default = true,否则取第一个。 + * + * @param index_header 段头文本, 如 "[[index]]" 或 "[[tool.uv.index]]" + * @param parent_table 父表头, 如 NULL (顶层) 或 "[tool.uv]" + * + * @return malloc 的 caller-free 字符串; 未找到返回 NULL。 + */ +static char * +uvh_get_index_url (const char *content, const char *index_header, const char *parent_table) +{ + if (parent_table && !uvh_find_table (content, parent_table)) return NULL; + + const char *ih = uvh_find_managed_index (content, index_header); + if (!ih) return NULL; + + const char *first = uvh_next_line (ih); + const char *end = uvh_find_section_end (first); + const char *url_line = uvh_find_key_in_section (first, end, "url"); + if (!url_line) return NULL; + return uvh_extract_string_value (url_line); +} + + +/* ============ uv.toml 便捷包装 (保持原函数名) ============ */ + +static char * +replace_pypi_index_url (const char *content, const char *url) +{ + return replace_index_url (content, url, "[[index]]", NULL); +} + +static char * +replace_python_install_mirror (const char *content, const char *url) +{ + return replace_key_value (content, "python-install-mirror", url, NULL); +} + +static char * +uvh_get_top_level_value (const char *content, const char *key) +{ + return uvh_get_value_in_table (content, key, NULL); +} diff --git a/src/recipe/lang/Python/uv.c b/src/recipe/lang/Python/uv.c index 7a08d8c5..3eef96af 100644 --- a/src/recipe/lang/Python/uv.c +++ b/src/recipe/lang/Python/uv.c @@ -37,18 +37,23 @@ pl_python_uv_prelude (void) /** + * @consult: https://docs.astral.sh/uv/concepts/configuration-files * chsrc get uv * - * uv 配置文件查找优先级: - * 1 ./uv.toml (项目级,已实现) - * 2. $workspaces/pyproject.toml (项目级,未实现,因过于罕见不考虑适配) - * 3. ~/.config/uv/uv.toml (用户级,已实现) - * 4. /etc/uv/uv.toml (系统级,考虑到权限问题不予考虑) + * uv 配置文件选择顺序 (chsrc 写入策略): + * 1. ./uv.toml (项目级;与 pyproject.toml 并存时 uv 优先读取它) + * 2. ./pyproject.toml (项目级 [tool.uv] 段) + * 3. 用户级 uv.toml + * + * uv 还会向父目录查找最近的项目配置,并合并用户/系统配置;chsrc 为避免 + * 越界修改父项目,只管理当前目录或明确的用户级配置文件。 */ -#define PL_Python_uv_ConfigFile "uv.toml" -#define PL_Python_uv_Local_ConfigPath "./" -#define PL_Python_uv_User_ConfigPath "~/.config/uv/" +#define PL_Python_uv_ConfigFile "uv.toml" +#define PL_Python_uv_PyprojectConfigFile "pyproject.toml" +/* 注意: 不能以 "./" 开头 —— Windows 上 chsrc_backup 的 copy 命令无法处理 */ +#define PL_Python_uv_Local_ConfigPath "" +#define PL_Python_uv_User_ConfigPath "~/.config/uv/" char * @@ -56,6 +61,16 @@ pl_python_find_uv_config (bool mkdir) { if (chsrc_in_project_scope_mode()) { + /* uv.toml 与 pyproject.toml 并存时,uv 会忽略后者中的 [tool.uv]。 */ + char *uv_toml = xy_2strcat (PL_Python_uv_Local_ConfigPath, PL_Python_uv_ConfigFile); + if (xy_file_exist (uv_toml)) + return uv_toml; + free (uv_toml); + + char *pyproject = xy_2strcat (PL_Python_uv_Local_ConfigPath, PL_Python_uv_PyprojectConfigFile); + if (xy_file_exist (pyproject)) + return pyproject; + free (pyproject); return xy_2strcat (PL_Python_uv_Local_ConfigPath, PL_Python_uv_ConfigFile); } else @@ -77,6 +92,7 @@ pl_python_find_uv_config (bool mkdir) chsrc_ensure_dir (config_dir); } char *result = xy_2strcat (config_dir, PL_Python_uv_ConfigFile); + free (config_dir); return result; } else @@ -103,35 +119,50 @@ pl_python_uv_getsrc (char *option) chsrc_error2 ("无法获取 uv 配置文件路径"); else chsrc_error2 ("未找到 uv 配置文件"); + free (uv_config); return; } - /* 获取 [[index]] 配置项的 url */ - if (xy.on_windows) + /* uv.toml 与 pyproject.toml 均使用同一套受限 TOML 读取逻辑。 */ + char *content = xy_file_read (uv_config); + if (!content) { - /* 在 Windows 上使用 PowerShell 替代 grep */ - char *script = xy_str_gsub (RAWSTR_pl_python_get_uv_config_on_windows, "@f@", uv_config); - chsrc_run_as_powershell_file (script); + chsrc_error2 ("无法读取 uv 配置文件"); + free (uv_config); + return; + } + + bool pyproject = xy_str_end_with (uv_config, PL_Python_uv_PyprojectConfigFile); + const char *index_header = pyproject ? "[[tool.uv.index]]" : "[[index]]"; + const char *parent_table = pyproject ? "[tool.uv]" : NULL; + + char *url = uvh_get_index_url (content, index_header, parent_table); + if (url) + { + say (url); + free (url); } else { - /* 在类 Unix 系统上使用 grep */ - char *cmd = xy_str_gsub (RAWSTR_pl_python_get_uv_config, "@f@", uv_config); - chsrc_run (cmd, RunOpt_Default); + if (ENGLISH) + chsrc_note2 ("No source configured in uv, showing default upstream source:"); + else + chsrc_note2 ("uv 中未配置源,显示默认上游源:"); + Source_t default_source = chsrc_yield_source (&pl_python_group_target, "upstream"); + say (default_source.url); } - /* 检查 Python 下载镜像 (顶层 python-install-mirror 键) */ - char *content = xy_file_read (uv_config); - if (content) + char *mirror = pyproject + ? uvh_get_value_in_table (content, "python-install-mirror", parent_table) + : uvh_get_top_level_value (content, "python-install-mirror"); + if (mirror) { - char *mirror = uvh_get_top_level_value (content, "python-install-mirror"); - if (mirror) - { - say (mirror); - free (mirror); /* uvh_get_top_level_value 返回 caller-free */ - } - free (content); + say (mirror); + free (mirror); } + + free (content); + free (uv_config); } @@ -144,6 +175,14 @@ pl_python_uv_getsrc (char *option) def_target (pl_uv_github_release, NULL); +/* CNB (Cloud Native Build, 腾讯) 托管的 python-build-standalone releases 镜像 */ +static MirrorSite_t CnbPython = +{ + IS_DedicatedMirrorSite, + "cnb", "CNB", "Cloud Native Build (腾讯)", "https://cnb.cool/", + {NotSkip, NA, NA, "https://cnb.cool/astral-sh/python-build-standalone/-/releases/download/20260728/cpython-3.14.6+20260728-i686-pc-windows-msvc-install_only_stripped.tar.gz", ACCURATE} +}; + /* 内部 target, 无 CLI 入口, 以下为占位 */ void pl_uv_github_release_getsrc (char *o) { (void)o; } void pl_uv_github_release_setsrc (char *o) { (void)o; } @@ -167,12 +206,14 @@ pl_uv_github_release_prelude (void) {&UpstreamProvider, "https://github.com/astral-sh/python-build-standalone/releases/download", DelegateToUpstream}, {&Nju, "https://mirrors.nju.edu.cn/github-release/astral-sh/python-build-standalone", FeedByPrelude}, {&Ustc, "https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone", FeedByPrelude}, - {&Lzuoss, "https://mirror.lzu.edu.cn/github-release/astral-sh/python-build-standalone", FeedByPrelude} + {&Lzuoss, "https://mirror.lzu.edu.cn/github-release/astral-sh/python-build-standalone", FeedByPrelude}, + {&CnbPython, "https://cnb.cool/astral-sh/python-build-standalone/-/releases/download", FeedByPrelude} def_sources_end () -#define GH_SM_POSTFIX "/20260510/cpython-3.14.5+20260510-i686-pc-windows-msvc-install_only_stripped.tar.gz" - chef_set_smURL_with_postfix (this, &Nju, GH_SM_POSTFIX); - chef_set_smURL_with_postfix (this, &Lzuoss, GH_SM_POSTFIX); +#define GH_SM_POSTFIX "/20260728/cpython-3.14.6+20260728-i686-pc-windows-msvc-install_only_stripped.tar.gz" + chef_set_smURL_with_postfix (this, &Nju, GH_SM_POSTFIX); + chef_set_smURL_with_postfix (this, &Lzuoss, GH_SM_POSTFIX); + chef_set_smURL_with_postfix (this, &CnbPython, GH_SM_POSTFIX); #undef GH_SM_POSTFIX /* 2026-5-31: USTC 仅保留 Latest, 只能用 SHA256SUMS 粗略测速 */ @@ -192,11 +233,25 @@ pl_python_uv_write_all (const char *uv_config, const char *pypi_url, const char char *content = xy_file_read (uv_config); if (!content) content = xy_strdup (""); - char *updated = replace_pypi_index_url (content, pypi_url); - - char *final = replace_python_install_mirror (updated, py_dl_url); + char *final = NULL; + if (xy_str_end_with (uv_config, PL_Python_uv_PyprojectConfigFile)) + { + /* pyproject.toml: 配置位于 [tool.uv] 表内 */ + char *updated = replace_index_url (content, pypi_url, "[[tool.uv.index]]", "[tool.uv]"); + final = replace_key_value (updated, "python-install-mirror", py_dl_url, "[tool.uv]"); + free (updated); + } + else + { + /* uv.toml: 配置位于顶层 */ + char *updated = replace_pypi_index_url (content, pypi_url); + final = replace_python_install_mirror (updated, py_dl_url); + free (updated); + } chsrc_overwrite_file (final, uv_config); + free (final); + free (content); } @@ -221,34 +276,32 @@ pl_python_uv_setsrc (char *option) return; } - /* reset: 清理 [[index]] 段 url 与 python-install-mirror 行 */ + /* reset: 把 index 与 python-install-mirror 写回默认上游 (PyPI 与 + * python-build-standalone), 不依赖 .bak 恢复旧 URL。 */ if (chsrc_in_reset_mode ()) { if (!chsrc_check_file (uv_config)) { chsrc_info ("没有 uv 配置文件, 无需重置"); + free (uv_config); return; } - /* 读内容, 清理, 写回 */ - char *content = xy_file_read (uv_config); - if (!content) - { - chsrc_error2 ("无法读取 uv 配置文件"); - return; - } + Source_t default_pypi = chsrc_yield_source (&pl_python_group_target, "upstream"); + Source_t default_gh = chsrc_yield_source (&pl_uv_github_release_target, "upstream"); - char *cleaned = cleanup_config_for_reset (content); - chsrc_overwrite_file (cleaned, uv_config); + chsrc_backup (uv_config); + pl_python_uv_write_all (uv_config, default_pypi.url, default_gh.url); + free (uv_config); return; } /* set: 选取源并写入 */ - Source_t source = chsrc_yield_source (&pl_python_group_target, option); - - /* 若 option 命中了 GitHub release 源则直接用, 否则自动测速 */ + char *pypi_opt = option; char *gh_opt = option; + + /* option 命中内部 gh 源 (如 cnb) 时: gh 直接用指定源, pypi 自动测速 */ if (gh_opt && !chsrc_in_reset_mode () && !hp_is_url (gh_opt)) { bool found = false; @@ -257,8 +310,13 @@ pl_python_uv_setsrc (char *option) for (int i = 0; i < pl_uv_github_release_target.sources_n; i++) if (xy_streql (pl_uv_github_release_target.sources[i].mirror->code, gh_opt)) { found = true; break; } - if (!found) gh_opt = NULL; + if (found) + pypi_opt = NULL; /* cnb 等是 gh 专用源, pypi 部分自动测速 */ + else + gh_opt = NULL; } + + Source_t source = chsrc_yield_source (&pl_python_group_target, pypi_opt); Source_t gh_source = chsrc_yield_source (&pl_uv_github_release_target, gh_opt); if (chsrc_in_standalone_mode()) @@ -266,6 +324,7 @@ pl_python_uv_setsrc (char *option) chsrc_backup (uv_config); pl_python_uv_write_all (uv_config, source.url, gh_source.url); + free (uv_config); if (chsrc_in_standalone_mode()) { diff --git a/test/uv-helper-test.c b/test/uv-helper-test.c index 7ff34c37..eb6da5d3 100644 --- a/test/uv-helper-test.c +++ b/test/uv-helper-test.c @@ -89,11 +89,11 @@ test_replace_pypi_index_url (void) CHECK_STR (r, "[[index]]\nurl = \"https://mirror\"\n"); free (r); - /* 多个 [[index]] 段: 只修改第一个 */ + /* 多个 [[index]] 段: 优先修改 default = true 的 PyPI 替代源 */ r = replace_pypi_index_url ( - "[[index]]\nurl = \"https://old1\"\n\n[[index]]\nurl = \"https://old2\"\n", + "[[index]]\nurl = \"https://private\"\ndefault = false\n\n[[index]]\nurl = \"https://old\"\ndefault = true\n", "https://mirror"); - CHECK_STR (r, "[[index]]\nurl = \"https://mirror\"\n\n[[index]]\nurl = \"https://old2\"\n"); + CHECK_STR (r, "[[index]]\nurl = \"https://private\"\ndefault = false\n\n[[index]]\nurl = \"https://mirror\"\ndefault = true\n"); free (r); /* 段边界: [[index]] 之后其他表的 url 键不被误改 */ @@ -152,61 +152,6 @@ test_replace_python_install_mirror (void) } -static void -test_cleanup_config_for_reset (void) -{ - char *r; - - /* 空内容 */ - r = cleanup_config_for_reset (""); - CHECK_STR (r, ""); - free (r); - - /* 只清理 [[index]] 的 url/default 与顶层 python-install-mirror; - * 其他表 ([python]) 内的 url 键必须保留 */ - r = cleanup_config_for_reset ( - "python-install-mirror = \"https://old\"\n" - "[[index]]\n" - "name = \"tuna\"\n" - "url = \"https://old\"\n" - "default = true\n" - "\n" - "[python]\n" - "url = \"https://python\"\n"); - CHECK_STR (r, - "[[index]]\n" - "name = \"tuna\"\n" - "\n" - "[python]\n" - "url = \"https://python\"\n"); - free (r); - - /* 顶层其他键保留 */ - r = cleanup_config_for_reset ( - "a = 1\npython-install-mirror = \"https://old\"\nb = 2\n"); - CHECK_STR (r, "a = 1\nb = 2\n"); - free (r); - - /* 注释行保留 (注释中的 url 键不误删) */ - r = cleanup_config_for_reset ( - "# url = \"comment\"\n[[index]]\nurl = \"real\"\n"); - CHECK_STR (r, "# url = \"comment\"\n[[index]]\n"); - free (r); - - /* 多个 [[index]] 段都清理 */ - r = cleanup_config_for_reset ( - "[[index]]\nurl = \"https://old1\"\n\n[[index]]\nurl = \"https://old2\"\n"); - CHECK_STR (r, "[[index]]\n\n[[index]]\n"); - free (r); - - /* 其他表内的 default 键保留 */ - r = cleanup_config_for_reset ( - "[python]\ndefault = \"x\"\n"); - CHECK_STR (r, "[python]\ndefault = \"x\"\n"); - free (r); -} - - static void test_uvh_get_top_level_value (void) { @@ -245,13 +190,154 @@ test_uvh_get_top_level_value (void) } +static void +test_replace_index_url_pyproject (void) +{ + char *r; + + /* 空文件: 创建 [tool.uv] 表 + [[tool.uv.index]] 段 */ + r = replace_index_url ("", "https://mirror", "[[tool.uv.index]]", "[tool.uv]"); + CHECK_STR (r, "[tool.uv]\n[[tool.uv.index]]\nurl = \"https://mirror\"\ndefault = true\n"); + free (r); + + /* 已有 [tool.uv] 无 index 段: 表内追加 (在下一个段头前) */ + r = replace_index_url ( + "[tool.uv]\nsome = 1\n[tool.poetry]\nx = 1\n", + "https://mirror", "[[tool.uv.index]]", "[tool.uv]"); + CHECK_STR (r, "[tool.uv]\nsome = 1\n[tool.poetry]\nx = 1\n[[tool.uv.index]]\nurl = \"https://mirror\"\ndefault = true\n"); + free (r); + + /* 已有 [tool.uv] + [[tool.uv.index]]: 替换 url */ + r = replace_index_url ( + "[tool.uv]\n[[tool.uv.index]]\nurl = \"https://old\"\ndefault = true\n", + "https://mirror", "[[tool.uv.index]]", "[tool.uv]"); + CHECK_STR (r, "[tool.uv]\n[[tool.uv.index]]\nurl = \"https://mirror\"\ndefault = true\n"); + free (r); + + /* 有 [project] 等其他表时正确找到 [tool.uv] */ + r = replace_index_url ( + "[project]\nname = \"x\"\n\n[tool.uv]\n[[tool.uv.index]]\nurl = \"https://old\"\n", + "https://mirror", "[[tool.uv.index]]", "[tool.uv]"); + CHECK_STR (r, "[project]\nname = \"x\"\n\n[tool.uv]\n[[tool.uv.index]]\nurl = \"https://mirror\"\n"); + free (r); + + /* [[tool.uv.index]] 段内无 url 键: 段内插入 url + default */ + r = replace_index_url ( + "[tool.uv]\n[[tool.uv.index]]\nname = \"mirror\"\n", + "https://mirror", "[[tool.uv.index]]", "[tool.uv]"); + CHECK_STR (r, "[tool.uv]\n[[tool.uv.index]]\nurl = \"https://mirror\"\ndefault = true\nname = \"mirror\"\n"); + free (r); + + /* CRLF 表头与缩进键应被识别,不能产生 duplicate table/key */ + r = replace_index_url ( + " [tool.uv]\r\n python-install-mirror = \"https://old-python\"\r\n [[tool.uv.index]]\r\n url = \"https://old\"\r\n default = true\r\n", + "https://mirror", "[[tool.uv.index]]", "[tool.uv]"); + CHECK_STR (r, + " [tool.uv]\r\n python-install-mirror = \"https://old-python\"\r\n [[tool.uv.index]]\r\n url = \"https://mirror\"\r\n default = true\r\n"); + free (r); + + /* CRLF 文件中无 [tool.uv]: 新建段同样使用 CRLF */ + r = replace_index_url ( + "[project]\r\nname = \"x\"\r\n", + "https://mirror", "[[tool.uv.index]]", "[tool.uv]"); + CHECK_STR (r, + "[project]\r\nname = \"x\"\r\n\r\n[tool.uv]\r\n[[tool.uv.index]]\r\nurl = \"https://mirror\"\r\ndefault = true\r\n"); + free (r); + + /* [tool.uv.sources] 不会阻止后续 index 被找到 */ + r = replace_index_url ( + "[tool.uv]\n[tool.uv.sources]\nfoo = { path = \"foo\" }\n[[tool.uv.index]]\nurl = \"https://old\"\ndefault = true\n", + "https://mirror", "[[tool.uv.index]]", "[tool.uv]"); + CHECK_STR (r, + "[tool.uv]\n[tool.uv.sources]\nfoo = { path = \"foo\" }\n[[tool.uv.index]]\nurl = \"https://mirror\"\ndefault = true\n"); + free (r); +} + + +static void +test_replace_key_value_pyproject (void) +{ + char *r; + + /* 空文件: 创建 [tool.uv] 表 + 键 */ + r = replace_key_value ("", "python-install-mirror", "https://mirror", "[tool.uv]"); + CHECK_STR (r, "[tool.uv]\npython-install-mirror = \"https://mirror\"\n"); + free (r); + + /* [tool.uv] 内已有键: 替换 */ + r = replace_key_value ( + "[tool.uv]\npython-install-mirror = \"https://old\"\n", + "python-install-mirror", "https://mirror", "[tool.uv]"); + CHECK_STR (r, "[tool.uv]\npython-install-mirror = \"https://mirror\"\n"); + free (r); + + /* [tool.uv] 内无该键但已有 [[tool.uv.index]] 子表: 键插在子表头前 */ + r = replace_key_value ( + "[tool.uv]\n[[tool.uv.index]]\nurl = \"https://pypi\"\n", + "python-install-mirror", "https://mirror", "[tool.uv]"); + CHECK_STR (r, "[tool.uv]\npython-install-mirror = \"https://mirror\"\n[[tool.uv.index]]\nurl = \"https://pypi\"\n"); + free (r); + + /* 缩进键就地替换,不重复定义 */ + r = replace_key_value ( + "[tool.uv]\r\n python-install-mirror = \"https://old\"\r\n", + "python-install-mirror", "https://mirror", "[tool.uv]"); + CHECK_STR (r, "[tool.uv]\r\n python-install-mirror = \"https://mirror\"\r\n"); + free (r); + + /* CRLF 文件中无 [tool.uv]: 新建表同样使用 CRLF */ + r = replace_key_value ( + "[project]\r\nname = \"x\"\r\n", + "python-install-mirror", "https://mirror", "[tool.uv]"); + CHECK_STR (r, + "[project]\r\nname = \"x\"\r\n\r\n[tool.uv]\r\npython-install-mirror = \"https://mirror\"\r\n"); + free (r); +} + + +static void +test_uvh_get_value_pyproject (void) +{ + char *v; + + /* 表内键提取 */ + v = uvh_get_value_in_table ( + "[tool.uv]\npython-install-mirror = \"https://x\"\n", "python-install-mirror", "[tool.uv]"); + CHECK (v != NULL); + if (v) { CHECK_STR (v, "https://x"); free (v); } + + /* 其他表内同名键: 找不到 */ + v = uvh_get_value_in_table ( + "[tool.other]\npython-install-mirror = \"https://x\"\n", "python-install-mirror", "[tool.uv]"); + CHECK (v == NULL); + + /* index url 提取 */ + v = uvh_get_index_url ( + "[tool.uv]\n[[tool.uv.index]]\nurl = \"https://pypi\"\ndefault = true\n", + "[[tool.uv.index]]", "[tool.uv]"); + CHECK (v != NULL); + if (v) { CHECK_STR (v, "https://pypi"); free (v); } + + /* 无 index 段: NULL */ + v = uvh_get_index_url ( + "[tool.uv]\nsome = 1\n", "[[tool.uv.index]]", "[tool.uv]"); + CHECK (v == NULL); + + /* 表不存在: NULL */ + v = uvh_get_index_url ("", "[[tool.uv.index]]", "[tool.uv]"); + CHECK (v == NULL); +} + + int main (void) { test_replace_pypi_index_url (); test_replace_python_install_mirror (); - test_cleanup_config_for_reset (); test_uvh_get_top_level_value (); + test_replace_index_url_pyproject (); + test_replace_key_value_pyproject (); + test_uvh_get_value_pyproject (); if (failures) {