ht32-code-mcp 0.3.1__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.1 → ht32_code_mcp-0.3.2}/PKG-INFO +1 -1
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/pyproject.toml +1 -1
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/materializer.py +14 -9
- {ht32_code_mcp-0.3.1 → 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.1 → ht32_code_mcp-0.3.2}/uv.lock +1 -1
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/.gitignore +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/.gitkeep +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/.pytest_base_final_run.log +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/DEPLOY.md +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/README.md +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/e2e_scenario_a.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/e2e_scenario_b.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/smoke_http.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/smoke_stdio.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/__init__.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/__main__.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/exporter.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/index.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/knowledge.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/release.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/src/ht32_code_mcp/usage.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/tests/conftest.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/tests/test_knowledge_grounding.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.3.2}/tests/test_tools.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']}"))
|
|
@@ -831,7 +836,7 @@ class Materializer:
|
|
|
831
836
|
(mdir / "validation.json").write_text(json.dumps({
|
|
832
837
|
"layers": validation,
|
|
833
838
|
"gate_rule": "gui/hardware 未验证项以 not-tested 明示,不得宣称已验证",
|
|
834
|
-
"compile_command": f'UV4 -b "{self.
|
|
839
|
+
"compile_command": f'UV4 -b "{self._local.project_rel}" -j0 -o build.log',
|
|
835
840
|
}, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
836
841
|
|
|
837
842
|
def _readme(self, r: Resolved, stem: str) -> str:
|
|
@@ -855,8 +860,8 @@ class Materializer:
|
|
|
855
860
|
"",
|
|
856
861
|
"## 打开与编译",
|
|
857
862
|
"",
|
|
858
|
-
f"- 打开 `{self.
|
|
859
|
-
f"- 命令行基线编译:`UV4 -b \"{self.
|
|
863
|
+
f"- 打开 `{self._local.project_rel}`(Target:`{stem}`)",
|
|
864
|
+
f"- 命令行基线编译:`UV4 -b \"{self._local.project_rel}\" -j0 -o build.log`",
|
|
860
865
|
]
|
|
861
866
|
if r.series == "ht32f5":
|
|
862
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}"
|
|
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
|
|
File without changes
|