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)
{