ht32-code-mcp 0.3.0__tar.gz → 0.3.2__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 (25) hide show
  1. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/PKG-INFO +1 -1
  2. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/pyproject.toml +1 -1
  3. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/materializer.py +26 -13
  4. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/server.py +6 -0
  5. ht32_code_mcp-0.3.2/tests/test_export_cloud_mode.py +47 -0
  6. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/tests/test_tools.py +4 -1
  7. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/uv.lock +1 -1
  8. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/.gitignore +0 -0
  9. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/.gitkeep +0 -0
  10. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/.pytest_base_final_run.log +0 -0
  11. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/DEPLOY.md +0 -0
  12. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/README.md +0 -0
  13. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/e2e_scenario_a.py +0 -0
  14. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/e2e_scenario_b.py +0 -0
  15. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/smoke_http.py +0 -0
  16. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/smoke_stdio.py +0 -0
  17. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/__init__.py +0 -0
  18. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/__main__.py +0 -0
  19. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/exporter.py +0 -0
  20. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/index.py +0 -0
  21. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/knowledge.py +0 -0
  22. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/release.py +0 -0
  23. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/usage.py +0 -0
  24. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/tests/conftest.py +0 -0
  25. {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/tests/test_knowledge_grounding.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ht32-code-mcp
3
- Version: 0.3.0
3
+ Version: 0.3.2
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.3.0"
7
+ version = "0.3.2"
8
8
  description = "HT32 Code MCP Server - 为 AI Agent 提供 HT32 函数库/例程/模板检索与工程导出"
9
9
  requires-python = ">=3.11"
10
10
  license = "MIT"
@@ -20,6 +20,7 @@ import os
20
20
  import re
21
21
  import shutil
22
22
  import tempfile
23
+ import threading
23
24
  import time
24
25
  import zipfile
25
26
  from pathlib import Path
@@ -381,6 +382,10 @@ class Materializer:
381
382
  self.raw = self.root / "raw"
382
383
  self.recipes = load_recipes(recipes_dir)
383
384
  self.recipes["__dir__"] = recipes_dir # resolve() 需要定位 adapter
385
+ # 单次调用的中间状态必须放 thread-local:物化器是进程级单例,
386
+ # 并发导出(MCP 工具在线程池里执行)会互相覆盖 _stage / _project_rel,
387
+ # 导致 "…is not in the subpath of…" 之类的相对路径串台错误。
388
+ self._local = threading.local()
384
389
 
385
390
  def resolve(self, *, config_id: str = "", chip: str = "", device: str = "",
386
391
  board: str = "", variant: str = "", clock_profile: str = "",
@@ -434,7 +439,7 @@ class Materializer:
434
439
  target.parent.mkdir(parents=True, exist_ok=True)
435
440
  shutil.copyfile(p, target)
436
441
  manifest_files.append(dict(
437
- path=(dst.relative_to(self._stage) / rel).as_posix(), bytes=p.stat().st_size,
442
+ path=(dst.relative_to(self._local.stage) / rel).as_posix(), bytes=p.stat().st_size,
438
443
  sha256=sha256_file(p), classification="official",
439
444
  origin=f"{pkg_name}/{src.relative_to(self.raw / pkg_name).as_posix()}/{rel}"))
440
445
 
@@ -456,7 +461,7 @@ class Materializer:
456
461
  # staging:与目标同文件系统的临时目录(保证原子重命名)
457
462
  out.parent.mkdir(parents=True, exist_ok=True)
458
463
  stage = Path(tempfile.mkdtemp(prefix=f".ht32-stage-{stem}-", dir=str(out.parent)))
459
- self._stage = stage
464
+ self._local.stage = stage
460
465
  manifest_files: list[dict] = []
461
466
  deviations: list[dict] = []
462
467
  try:
@@ -497,7 +502,7 @@ class Materializer:
497
502
  shutil.rmtree(stage, ignore_errors=True)
498
503
  except BaseException: # noqa: BLE001 - best-effort cleanup
499
504
  pass
500
- return MaterialResult(out_dir=out, project_file=self._project_rel,
505
+ return MaterialResult(out_dir=out, project_file=self._local.project_rel,
501
506
  files=len(manifest_files), bytes=total_bytes,
502
507
  sha256=tree_hash, manifest_dir="manifest",
503
508
  warnings=r.warnings, deviations=deviations)
@@ -509,7 +514,7 @@ class Materializer:
509
514
  cfg = r.config
510
515
  origin = cfg["origin"]
511
516
  pkg = self.raw / origin["package"]
512
- self._stage = stage
517
+ self._local.stage = stage
513
518
 
514
519
  # 1) library:完整官方树(complete-official-tree)
515
520
  lib_src = pkg / ("libraries" if kernel == "m4" else "library")
@@ -618,9 +623,9 @@ class Materializer:
618
623
  proj_dir = stage / "project" / "MDK_ARMv5"
619
624
  proj_dir.mkdir(parents=True, exist_ok=True)
620
625
  uvprojx = self._materialize_uvprojx(r, proj_dir, stem, kernel, board_dir, deviations)
621
- self._project_rel = uvprojx.relative_to(stage).as_posix()
626
+ self._local.project_rel = uvprojx.relative_to(stage).as_posix()
622
627
  uv_data = uvprojx.read_bytes()
623
- manifest_files.append(dict(path=self._project_rel, bytes=len(uv_data),
628
+ manifest_files.append(dict(path=self._local.project_rel, bytes=len(uv_data),
624
629
  sha256=sha256_bytes(uv_data),
625
630
  classification="materialized-project",
626
631
  origin=f"{origin['package']}/{origin['project']}"))
@@ -714,18 +719,24 @@ class Materializer:
714
719
  text = text.replace("<umfTarg>0</umfTarg>", "<umfTarg>1</umfTarg>", 1)
715
720
  edits.append("umfTarg 0->1 (Use Memory Layout from Target Dialog)")
716
721
  # FlashDriverDll:-FL 与裁决容量一致(官方逐 IC 值优先;仅裁决覆盖时改写)
722
+ # 注意 UL2CM3 参数是**按算法块分块**的:
723
+ # UL2CM3(... -FF0HT32F -FS00 -FL<主 Flash 容量> -FF1HT32F_OPT -FS11FF00000 -FL<OPT 区长度>)
724
+ # 裁决容量只属于 block 0(主 Flash);block 1 是 Option Bytes 区(另一基址/另一长度),
725
+ # 改写它会把 OPT 区长度错写成主 Flash 大小。故 count=1 只改首个 -FL。
717
726
  if origin["kind"] == "official-per-ic-project":
718
727
  m = re.search(r"(<FlashDriverDll>)(.*?)(</FlashDriverDll>)", text, re.S)
719
728
  if m and "-FL" in m.group(2):
720
729
  fdd = m.group(2)
721
730
  fl = re.search(r"-FL([0-9A-Fa-f]+)", fdd)
722
731
  if fl and int(fl.group(1), 16) != flash_size:
723
- new_fdd, n = re.subn(r"-FL[0-9A-Fa-f]+", f"-FL{flash_size:X}", fdd)
732
+ new_fdd, n = re.subn(r"-FL[0-9A-Fa-f]+", f"-FL{flash_size:X}", fdd,
733
+ count=1)
724
734
  if n != 1:
725
- raise ContractError(RECIPE_MISMATCH, f"{what}: FlashDriverDll -FL 命中异常")
735
+ raise ContractError(RECIPE_MISMATCH,
736
+ f"{what}: FlashDriverDll -FL 命中异常")
726
737
  text = text[:m.start()] + m.group(1) + new_fdd + m.group(3) + text[m.end():]
727
738
  edits.append(f"FlashDriverDll -FL{fl.group(1)} -> -FL{flash_size:X} "
728
- f"(LIBCFG adjudication)")
739
+ f"(LIBCFG adjudication, main alg block only)")
729
740
  # d) dfp-derived:Define/RegisterFile/SFDFile 的器件宏替换
730
741
  if origin["kind"] == "dfp-derived":
731
742
  base_dev = orig_device
@@ -825,7 +836,7 @@ class Materializer:
825
836
  (mdir / "validation.json").write_text(json.dumps({
826
837
  "layers": validation,
827
838
  "gate_rule": "gui/hardware 未验证项以 not-tested 明示,不得宣称已验证",
828
- "compile_command": f'UV4 -b "{self._project_rel}" -j0 -o build.log',
839
+ "compile_command": f'UV4 -b "{self._local.project_rel}" -j0 -o build.log',
829
840
  }, ensure_ascii=False, indent=1), encoding="utf-8")
830
841
 
831
842
  def _readme(self, r: Resolved, stem: str) -> str:
@@ -841,14 +852,16 @@ class Materializer:
841
852
  "",
842
853
  "## 前置条件",
843
854
  "",
844
- f"- Keil MDK-ARM 5.x(AC5;本地以 ARMCC V5.06 update 4/7 实测)",
855
+ f"- Keil MDK-ARM 5.x,编译器 **AC5(armcc)**(本地以 ARMCC V5.06 update 4/7 实测)"
856
+ f"——**本工程暂不支持 AC6(armclang)**,请在 Options for Target → Target 保持"
857
+ f"编译器版本为 AC5;切到 AC6 可能在编译/链接阶段报错",
845
858
  f"- {r.recipe['pack']['device_macro_note']}(manifest 锁定 "
846
859
  f"`{r.recipe['pack']['id']}`)",
847
860
  "",
848
861
  "## 打开与编译",
849
862
  "",
850
- f"- 打开 `{self._project_rel}`(Target:`{stem}`)",
851
- f"- 命令行基线编译:`UV4 -b \"{self._project_rel}\" -j0 -o build.log`",
863
+ f"- 打开 `{self._local.project_rel}`(Target:`{stem}`)",
864
+ f"- 命令行基线编译:`UV4 -b \"{self._local.project_rel}\" -j0 -o build.log`",
852
865
  ]
853
866
  if r.series == "ht32f5":
854
867
  lines.append("- 调试:工程自带受控 `.uvoptx`(CMSIS-DAP + Reset and Run 配置;"
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import os
9
9
  import sys
10
+ import time
10
11
  from importlib.metadata import PackageNotFoundError, version as pkg_version
11
12
  from pathlib import Path
12
13
  from typing import Literal
@@ -43,6 +44,11 @@ BUNDLE_BASE_URL = os.environ.get("BUNDLE_BASE_URL", "").strip()
43
44
  BUNDLE_TTL = _env_int("BUNDLE_TTL_SECONDS", DEFAULT_BUNDLE_TTL)
44
45
  CLOUD_BUNDLE = bool(BUNDLE_DIR and BUNDLE_BASE_URL)
45
46
 
47
+
48
+ def _fmt_time(ts: float) -> str:
49
+ """epoch 秒 → 本地时间字符串(用于云端 zip 下载的有效期提示)。"""
50
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
51
+
46
52
  # 反馈与用量日志:FEEDBACK_ROOT(宿主机挂载的可写卷,与 wiki 同一套约定)
47
53
  # 下按服务名分子目录,本服务写 <FEEDBACK_ROOT>/code/。
48
54
  # 为空则整体关闭——本地开发默认不落盘。注意:日志含 query 原文,勿提交进仓库。
@@ -0,0 +1,47 @@
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}"
@@ -275,7 +275,10 @@ def _bundle_checks_v2(out_dir: Path, device: str, config_id: str = ""):
275
275
  # 模板自用文件不进用户工程;README 属于用户工程
276
276
  assert not (out_dir / "devices.json").exists()
277
277
  assert not (out_dir / "TEMPLATE.md").exists()
278
- assert (out_dir / "README.md").read_text(encoding="utf-8").startswith(f"# {device}")
278
+ readme = (out_dir / "README.md").read_text(encoding="utf-8")
279
+ assert readme.startswith(f"# {device}")
280
+ # 工具链声明必须随产物交付(仅 AC5;AC6 暂不支持)
281
+ assert "AC5" in readme and "不支持 AC6" in readme, "导出 README 未声明仅支持 AC5"
279
282
  # 完整官方库(自包含) + 文件清单逐条登记
280
283
  files = json.loads((mdir / "files.json").read_text(encoding="utf-8"))
281
284
  registered = {f["path"] for f in files["files"]}
@@ -440,7 +440,7 @@ wheels = [
440
440
 
441
441
  [[package]]
442
442
  name = "ht32-code-mcp"
443
- version = "0.3.0"
443
+ version = "0.3.2"
444
444
  source = { editable = "." }
445
445
  dependencies = [
446
446
  { name = "fastmcp" },
File without changes
File without changes
File without changes
File without changes