ht32-code-mcp 0.4.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/PKG-INFO +1 -1
  2. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/pyproject.toml +1 -1
  3. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/materializer.py +42 -17
  4. ht32_code_mcp-0.4.1/tests/test_export_cloud_mode.py +83 -0
  5. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/uv.lock +1 -1
  6. ht32_code_mcp-0.4.0/tests/test_export_cloud_mode.py +0 -47
  7. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/.gitignore +0 -0
  8. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/.gitkeep +0 -0
  9. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/.pytest_base_final_run.log +0 -0
  10. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/DEPLOY.md +0 -0
  11. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/README.md +0 -0
  12. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/e2e_scenario_a.py +0 -0
  13. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/e2e_scenario_b.py +0 -0
  14. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/smoke_http.py +0 -0
  15. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/smoke_stdio.py +0 -0
  16. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/__init__.py +0 -0
  17. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/__main__.py +0 -0
  18. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/exporter.py +0 -0
  19. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/index.py +0 -0
  20. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/knowledge.py +0 -0
  21. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/release.py +0 -0
  22. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/server.py +0 -0
  23. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/src/ht32_code_mcp/usage.py +0 -0
  24. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/tests/conftest.py +0 -0
  25. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/tests/test_knowledge_grounding.py +0 -0
  26. {ht32_code_mcp-0.4.0 → ht32_code_mcp-0.4.1}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ht32-code-mcp
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: HT32 Code MCP Server - 为 AI Agent 提供 HT32 函数库/例程/模板检索与工程导出
5
5
  License-Expression: MIT
6
6
  Keywords: firmware,holtek,ht32,mcp,mcu
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ht32-code-mcp"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "HT32 Code MCP Server - 为 AI Agent 提供 HT32 函数库/例程/模板检索与工程导出"
9
9
  requires-python = ">=3.11"
10
10
  license = "MIT"
@@ -652,9 +652,11 @@ class Materializer:
652
652
  # 4) Src_USER:官方字节 + 声明选项编辑 + 自有 main.c
653
653
  src_user = stage / "project" / "Src_USER"
654
654
  src_user.mkdir(parents=True, exist_ok=True)
655
- def _resolve_target(stage: Path, target_path: str) -> Path | None:
655
+ def _resolve_target(stage: Path, target_path: str, *,
656
+ require_existing: bool = False) -> Path | None:
656
657
  """源集目标路径(相对工程文件目录)-> staging 内实际路径;
657
- library/utilities 下的文件由完整树整体拷贝负责,不重复复制(返回 None)。"""
658
+ library/utilities 下的文件由完整树整体拷贝负责,默认返回 None(不重复复制);
659
+ require_existing=True 时返回树内路径,供「就地改写已拷贝字节」使用。"""
658
660
  tp = target_path.replace("/", "\\")
659
661
  if tp.startswith(".\\"):
660
662
  return stage / "project" / "MDK_ARMv5" / tp[2:]
@@ -663,7 +665,7 @@ class Materializer:
663
665
  if tp.startswith("..\\..\\"):
664
666
  rest = tp[len("..\\..\\"):]
665
667
  if rest.startswith(("library\\", "utilities\\", "component\\")):
666
- return None # 完整官方树已拷贝
668
+ return stage / rest if require_existing else None
667
669
  return stage / rest
668
670
  raise ContractError(RECIPE_MISMATCH, f"无法解析目标路径: {target_path}")
669
671
 
@@ -675,8 +677,15 @@ class Materializer:
675
677
  reason="官方示例文档,非构建资产"))
676
678
  continue
677
679
  target_file = _resolve_target(stage, sf["target_path"])
678
- if target_file is None:
679
- continue # library/utilities 完整树已覆盖(manifest 由树拷贝登记)
680
+ in_tree = target_file is None
681
+ base_name = Path(sf["origin"]).name
682
+ if in_tree:
683
+ # library/utilities 下的文件由完整官方树按字节拷贝(manifest 已由树拷贝登记);
684
+ # 只有带声明式选项编辑的文件才就地改写,其余保持原样、不重复登记。
685
+ if not any(Path(e["origin"]).name == base_name
686
+ for e in cfg.get("allowed_option_edits", [])):
687
+ continue
688
+ target_file = _resolve_target(stage, sf["target_path"], require_existing=True)
680
689
  op = pkg / sf["origin"]
681
690
  data = op.read_bytes()
682
691
  fname = target_file.name
@@ -684,7 +693,6 @@ class Materializer:
684
693
  # retarget 默认开启 = 官方默认值(_RETARGET=1),无需编辑;
685
694
  # 非 hsi-safe 时钟 profile 用官方默认选项,不应用时钟编辑。
686
695
  applicable = []
687
- base_name = Path(sf["origin"]).name
688
696
  for e in cfg.get("allowed_option_edits", []):
689
697
  if Path(e["origin"]).name != base_name:
690
698
  continue
@@ -701,13 +709,26 @@ class Materializer:
701
709
  selector=e["selector"]["name"], old=e["old_value"], new=e["new_value"],
702
710
  expected_matches=e["expected_matches"],
703
711
  reason=f"profile {r.clock_profile}/{r.retarget_profile} 声明式官方选项差异"))
704
- target_file.parent.mkdir(parents=True, exist_ok=True)
705
- target_file.write_bytes(data)
706
- manifest_files.append(dict(
707
- path=target_file.relative_to(stage).as_posix(), bytes=len(data),
708
- sha256=sha256_bytes(data), classification="official",
709
- origin=f"{origin['package']}/{sf['origin']}",
710
- edited=bool(applicable)))
712
+ if in_tree:
713
+ # 就地改写树拷贝字节,并更新树拷贝已登记的那条 manifest(字节/SHA/edited)
714
+ target_file.write_bytes(data)
715
+ rel = target_file.relative_to(stage).as_posix()
716
+ entry = next((m for m in manifest_files if m["path"] == rel), None)
717
+ if entry is None:
718
+ manifest_files.append(dict(
719
+ path=rel, bytes=len(data), sha256=sha256_bytes(data),
720
+ classification="official",
721
+ origin=f"{origin['package']}/{sf['origin']}", edited=True))
722
+ else:
723
+ entry.update(bytes=len(data), sha256=sha256_bytes(data), edited=True)
724
+ else:
725
+ target_file.parent.mkdir(parents=True, exist_ok=True)
726
+ target_file.write_bytes(data)
727
+ manifest_files.append(dict(
728
+ path=target_file.relative_to(stage).as_posix(), bytes=len(data),
729
+ sha256=sha256_bytes(data), classification="official",
730
+ origin=f"{origin['package']}/{sf['origin']}",
731
+ edited=bool(applicable)))
711
732
  # 自有 main.c
712
733
  main_c = owned_main_source(r, cfg["device"])
713
734
  (src_user / "main.c").write_text(main_c, encoding="utf-8", newline="")
@@ -979,14 +1000,18 @@ class Materializer:
979
1000
  else:
980
1001
  lines.append("- 调试:本系列无受控 `.uvoptx`,首次打开请在 Options for Target → Debug "
981
1002
  "选择 `CMSIS-DAP Debugger` 并保存")
1003
+ result_khz = (r.config.get("clock") or {}).get("result_khz")
1004
+ if r.clock_profile in ("hsi-safe", "hick-safe"):
1005
+ clock_detail = ("(内部高速时钟安全配置,不依赖外晶振;"
1006
+ + (f"HSI 时钟 {result_khz / 1000:g} MHz;" if result_khz else "")
1007
+ + "官方选项组合)")
1008
+ else:
1009
+ clock_detail = "(官方默认时钟配置,依赖板级外晶振)"
982
1010
  lines += [
983
1011
  "",
984
1012
  "## 导出 profile",
985
1013
  "",
986
- f"- 时钟:`{r.clock_profile}`"
987
- + ("(内部高速时钟安全配置,不依赖外晶振;官方选项组合)"
988
- if r.clock_profile in ("hsi-safe", "hick-safe")
989
- else "(官方默认时钟配置,依赖板级外晶振)"),
1014
+ f"- 时钟:`{r.clock_profile}`{clock_detail}",
990
1015
  f"- 串口:`{r.retarget_profile}`",
991
1016
  ]
992
1017
  if r.retarget_profile == "retarget":
@@ -0,0 +1,83 @@
1
+ """回归测试:导出侧实测暴露的三个缺陷。
2
+
3
+ 1) 云端 zip 模式下响应格式化用了未定义的 `_fmt_time` → 线上 `export_project_bundle`
4
+ 100% 失败(本地模式与直接调 `exporter.export_zip` 都覆盖不到这行)。
5
+ 2) 物化器的单次调用状态(`_stage` / `_project_rel`)曾挂在进程级单例实例上,
6
+ 并发导出互相覆盖 → "…is not in the subpath of…" 串台错误。
7
+ 3) 默认时钟 profile `hsi-safe` 曾整体失效:官方 system 文件在 `library/` 下,由完整官方树
8
+ 按字节拷贝,而选项编辑只作用于 `_resolve_target` 能定位到 staging 的文件 → 声明式编辑
9
+ 从未落到 system 文件,导出工程仍依赖外部晶振(HSE)。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import concurrent.futures
14
+ import json
15
+ import re
16
+
17
+ from ht32_code_mcp import server as srv
18
+
19
+ CFG_F52352 = "ht32f52352.ht32f52352-sk.ac5.hsi-safe.retarget"
20
+ CFG_F50241 = "ht32f50241.ht32f50241-sk.ac5.hsi-safe.retarget"
21
+ CFG_L52241 = "ht32l52241.ht32l52241-sk.ac5.hsi-safe.retarget"
22
+
23
+
24
+ def test_cloud_zip_response_formats_expiry(tmp_path, monkeypatch):
25
+ """云端模式必须能给出下载 URL 与有效期(不得因未定义的 helper 直接抛 NameError)。"""
26
+ monkeypatch.setattr(srv, "CLOUD_BUNDLE", True)
27
+ monkeypatch.setattr(srv, "BUNDLE_DIR", str(tmp_path / "bundles"))
28
+ monkeypatch.setattr(srv, "BUNDLE_BASE_URL", "http://example.test/ht32-bundles")
29
+ text = srv.export_project_bundle(config_id=CFG_F52352, project_name="CloudMode")
30
+ assert "导出失败" not in text, text
31
+ assert "下载 URL" in text and "http://example.test/ht32-bundles/" in text
32
+ assert "有效期" in text
33
+ assert list(tmp_path.joinpath("bundles").glob("*.zip")), "未生成 zip 产物"
34
+
35
+
36
+ def test_concurrent_exports_do_not_cross_talk(tmp_path):
37
+ """并发导出必须各自落到自己的目录(不得因共享实例状态串台)。"""
38
+ jobs = [(CFG_F50241, "ConcA"), (CFG_L52241, "ConcB")]
39
+
40
+ def run(item):
41
+ idx, (cid, name) = item
42
+ return srv.export_project_bundle(
43
+ config_id=cid, project_name=name, out_dir=str(tmp_path / f"out{idx}"))
44
+
45
+ with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
46
+ texts = list(pool.map(run, enumerate(jobs)))
47
+
48
+ for text, (_, name) in zip(texts, jobs):
49
+ assert "导出失败" not in text, text
50
+ for idx, (_, name) in enumerate(jobs):
51
+ proj = tmp_path / f"out{idx}" / "project" / "MDK_ARMv5" / f"{name}.uvprojx"
52
+ assert proj.is_file(), f"并发导出产物缺失:{proj}"
53
+
54
+
55
+ def test_hsi_safe_profile_edits_system_file_in_library_tree(tmp_path):
56
+ """hsi-safe 默认 profile 必须真正改写官方 system 文件(不得因 tree 拷贝路径整条跳过)。
57
+
58
+ F50241 的官方 system 文件属 hse-direct 家族(HCLK_SRC=2/HSE),编辑器应把它改成
59
+ HSE_ENABLE=0 + HCLK_SRC=3(HSI,20 MHz 满频),并在 deviations 留 option-edit 记录。
60
+ """
61
+ out = tmp_path / "out"
62
+ text = srv.export_project_bundle(config_id=CFG_F50241, project_name="HsiSafe",
63
+ out_dir=str(out))
64
+ assert "导出失败" not in text, text
65
+
66
+ # 完整官方树里有 ~20 个 system_*.c;只有本配置选中的那个应被改写
67
+ dev = json.loads((out / "manifest" / "deviations.json").read_text(encoding="utf-8"))
68
+ edits = [d for d in dev["deviations"] if d["kind"] == "option-edit"]
69
+ assert {d["selector"] for d in edits} >= {"HSE_ENABLE", "HCLK_SRC"}, f"缺 option-edit 记录:{edits}"
70
+ assert len({d["file"] for d in edits}) == 1, f"option-edit 跨了多个文件:{edits}"
71
+ sys_name = edits[0]["file"]
72
+
73
+ target = next((p for p in out.glob("library/Device/**/system_*.c") if p.name == sys_name), None)
74
+ assert target is not None, f"导出物里找不到 {sys_name}"
75
+ code = target.read_text(encoding="utf-8", errors="replace")
76
+ assert re.search(r"#define\s+HSE_ENABLE\s+\(0\)", code), "HSE_ENABLE 未被改写为 0"
77
+ assert re.search(r"#define\s+HCLK_SRC\s+\(3\)", code), "HCLK_SRC 未被改写为 3(HSI)"
78
+
79
+ files = json.loads((out / "manifest" / "files.json").read_text(encoding="utf-8"))
80
+ paths = [f["path"] for f in files["files"]]
81
+ assert len(paths) == len(set(paths)), "同一路径被重复登记(树拷贝 + 源集各一次)"
82
+ entry = next(f for f in files["files"] if f["path"].endswith("/" + sys_name))
83
+ assert entry.get("edited") is True, "system 文件的 manifest 条目未标记 edited"
@@ -440,7 +440,7 @@ wheels = [
440
440
 
441
441
  [[package]]
442
442
  name = "ht32-code-mcp"
443
- version = "0.4.0"
443
+ version = "0.4.1"
444
444
  source = { editable = "." }
445
445
  dependencies = [
446
446
  { name = "fastmcp" },
@@ -1,47 +0,0 @@
1
- """回归测试:线上实测暴露的两个导出缺陷。
2
-
3
- 1) 云端 zip 模式下响应格式化用了未定义的 `_fmt_time` → 线上 `export_project_bundle`
4
- 100% 失败(本地模式与直接调 `exporter.export_zip` 都覆盖不到这行)。
5
- 2) 物化器的单次调用状态(`_stage` / `_project_rel`)曾挂在进程级单例实例上,
6
- 并发导出互相覆盖 → "…is not in the subpath of…" 串台错误。
7
- """
8
- from __future__ import annotations
9
-
10
- import concurrent.futures
11
-
12
- from ht32_code_mcp import server as srv
13
-
14
- CFG_F52352 = "ht32f52352.ht32f52352-sk.ac5.hsi-safe.retarget"
15
- CFG_F50241 = "ht32f50241.ht32f50241-sk.ac5.hsi-safe.retarget"
16
- CFG_L52241 = "ht32l52241.ht32l52241-sk.ac5.hsi-safe.retarget"
17
-
18
-
19
- def test_cloud_zip_response_formats_expiry(tmp_path, monkeypatch):
20
- """云端模式必须能给出下载 URL 与有效期(不得因未定义的 helper 直接抛 NameError)。"""
21
- monkeypatch.setattr(srv, "CLOUD_BUNDLE", True)
22
- monkeypatch.setattr(srv, "BUNDLE_DIR", str(tmp_path / "bundles"))
23
- monkeypatch.setattr(srv, "BUNDLE_BASE_URL", "http://example.test/ht32-bundles")
24
- text = srv.export_project_bundle(config_id=CFG_F52352, project_name="CloudMode")
25
- assert "导出失败" not in text, text
26
- assert "下载 URL" in text and "http://example.test/ht32-bundles/" in text
27
- assert "有效期" in text
28
- assert list(tmp_path.joinpath("bundles").glob("*.zip")), "未生成 zip 产物"
29
-
30
-
31
- def test_concurrent_exports_do_not_cross_talk(tmp_path):
32
- """并发导出必须各自落到自己的目录(不得因共享实例状态串台)。"""
33
- jobs = [(CFG_F50241, "ConcA"), (CFG_L52241, "ConcB")]
34
-
35
- def run(item):
36
- idx, (cid, name) = item
37
- return srv.export_project_bundle(
38
- config_id=cid, project_name=name, out_dir=str(tmp_path / f"out{idx}"))
39
-
40
- with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
41
- texts = list(pool.map(run, enumerate(jobs)))
42
-
43
- for text, (_, name) in zip(texts, jobs):
44
- assert "导出失败" not in text, text
45
- for idx, (_, name) in enumerate(jobs):
46
- proj = tmp_path / f"out{idx}" / "project" / "MDK_ARMv5" / f"{name}.uvprojx"
47
- assert proj.is_file(), f"并发导出产物缺失:{proj}"
File without changes
File without changes
File without changes
File without changes