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.
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/PKG-INFO +1 -1
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/pyproject.toml +1 -1
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/materializer.py +26 -13
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/server.py +6 -0
- ht32_code_mcp-0.3.2/tests/test_export_cloud_mode.py +47 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/tests/test_tools.py +4 -1
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/uv.lock +1 -1
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/.gitignore +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/.gitkeep +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/.pytest_base_final_run.log +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/DEPLOY.md +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/README.md +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/e2e_scenario_a.py +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/e2e_scenario_b.py +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/smoke_http.py +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/smoke_stdio.py +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/__init__.py +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/__main__.py +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/exporter.py +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/index.py +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/knowledge.py +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/release.py +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/usage.py +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/tests/conftest.py +0 -0
- {ht32_code_mcp-0.3.0 → ht32_code_mcp-0.3.2}/tests/test_knowledge_grounding.py +0 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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.
|
|
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(
|
|
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.
|
|
851
|
-
f"- 命令行基线编译:`UV4 -b \"{self.
|
|
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
|
-
|
|
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"]}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|