infinity_make 1.0.0__py3-none-any.whl
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.
- infinity_make/__init__.py +16 -0
- infinity_make/__main__.py +8 -0
- infinity_make/executor.py +286 -0
- infinity_make/main.py +151 -0
- infinity_make/nixenv.py +395 -0
- infinity_make/py.typed +0 -0
- infinity_make/schemas/env.inft +28 -0
- infinity_make/vfs/__init__.py +3 -0
- infinity_make/vfs/vfs.py +78 -0
- infinity_make-1.0.0.dist-info/METADATA +272 -0
- infinity_make-1.0.0.dist-info/RECORD +14 -0
- infinity_make-1.0.0.dist-info/WHEEL +5 -0
- infinity_make-1.0.0.dist-info/entry_points.txt +2 -0
- infinity_make-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""infinity_make —— expr 模式构建系统:infmake.infd 是一棵操作树。
|
|
2
|
+
|
|
3
|
+
分层结构:
|
|
4
|
+
|
|
5
|
+
- :mod:`infinity_make.executor`:操作树执行器(子图去重 + VFS 注入 + 节点缓存)
|
|
6
|
+
- :mod:`infinity_make.nixenv`:Nix 环境引导(env.infd → ``nix develop`` 重入 / ``--run``)
|
|
7
|
+
- :mod:`infinity_make.main`:CLI 薄壳(``infmake`` 入口)
|
|
8
|
+
- :mod:`infinity_make.vfs`:构建工作区 VFS(临时目录 + 按 key 持久缓存)
|
|
9
|
+
|
|
10
|
+
公共 API::class:`Executor`、:class:`BuildError`、:func:`main`。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from infinity_make.executor import BuildError, Executor
|
|
14
|
+
from infinity_make.main import main
|
|
15
|
+
|
|
16
|
+
__all__ = ['BuildError', 'Executor', 'main']
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""操作树执行器:子图 hash 去重 + VFS 注入 + 持久化节点缓存。
|
|
2
|
+
|
|
3
|
+
每个操作模板 = 数据字段 + _impl(内嵌 Python 实现)。执行器遍历操作树:
|
|
4
|
+
|
|
5
|
+
1. 先求值数据字段(子节点先「物化」为记录:输入 + 计算输出)
|
|
6
|
+
2. 调用节点自带 impl(**数据字段)
|
|
7
|
+
3. impl 返回的 dict 合并回节点记录(覆盖语义:计算结果优先)
|
|
8
|
+
4. 根节点最终 = 完整物化树(自包含、可审计、可写为 JSON 产物)
|
|
9
|
+
|
|
10
|
+
进阶特性:
|
|
11
|
+
- 子图 hash 去重:相同(模板 + 实现 + 物化输入)的子图只求值一次,结果复用
|
|
12
|
+
- VFS 注入:impl 签名含 ``vfs`` 参数时注入工作区 VFS(临时目录分配 + 按 key 缓存)
|
|
13
|
+
- 持久化节点缓存:<工作区>/node_cache/<hash>.json,``dump_to_json`` /
|
|
14
|
+
``load_from_json`` 无损往返(Decimal / path / noexist 以自描述标记编码)
|
|
15
|
+
|
|
16
|
+
与 infd_builder 玩具版的差异(上游 infinity_data 3.x API):
|
|
17
|
+
- 无模板 dataclass 生成(templates.py 类型契约)与自动 dataclass 注解扫描注入:
|
|
18
|
+
impl 收到的参数是**降维后的普通 Python 值**(dict / list / 标量 / PurePosixPath),
|
|
19
|
+
不再按注解实例化 dataclass
|
|
20
|
+
|
|
21
|
+
约定:
|
|
22
|
+
- 入口字段为顶层 ``target``(一棵操作树)
|
|
23
|
+
- ``_impl`` 源码内定义名为 ``impl`` 的函数(与模板名解耦)
|
|
24
|
+
- ``_impl`` / ``_gen`` / ``_cache`` 是「元数据」字段,不进物化记录
|
|
25
|
+
- 无 ``_impl`` 的节点是纯数据节点:直接物化为记录
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import inspect
|
|
30
|
+
from decimal import Decimal
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from infinity_data import CompilationResult
|
|
35
|
+
from infinity_data.emit import dump_to_json, load_from_json
|
|
36
|
+
from infinity_data.semantic.std import (
|
|
37
|
+
StdArray,
|
|
38
|
+
StdField,
|
|
39
|
+
StdLiteral,
|
|
40
|
+
StdObject,
|
|
41
|
+
StdValue,
|
|
42
|
+
python_to_std,
|
|
43
|
+
std_to_python,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
from infinity_make.vfs import VFS, NodeVFS
|
|
47
|
+
|
|
48
|
+
ENTRY_FIELD = 'target'
|
|
49
|
+
"""顶层入口字段:操作树根。"""
|
|
50
|
+
|
|
51
|
+
IMPL_FIELD = '_impl'
|
|
52
|
+
"""实现字段:内嵌 Python 源码(定义 ``impl`` 函数)。"""
|
|
53
|
+
|
|
54
|
+
GEN_FIELD = '_gen'
|
|
55
|
+
"""生成字段声明:模板里 ``_gen: dict = {...}`` 声明 impl 输出字段。
|
|
56
|
+
|
|
57
|
+
元数据(同 ``_impl``),不进物化记录。"""
|
|
58
|
+
|
|
59
|
+
CACHE_FIELD = '_cache'
|
|
60
|
+
"""节点缓存开关:模板声明 ``_cache: bool = false`` 关闭持久化缓存(每次执行 impl)。
|
|
61
|
+
|
|
62
|
+
默认 true(参与 <hash>.json 节点缓存)。元数据字段,不进物化记录。"""
|
|
63
|
+
|
|
64
|
+
VFS_PARAM = 'vfs'
|
|
65
|
+
"""impl 签名中的 VFS 注入参数名:声明即注入。"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class BuildError(Exception):
|
|
69
|
+
"""构建错误:编译失败、缺 impl、执行失败。"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _tpl_name(node: StdObject) -> str:
|
|
73
|
+
"""节点模板名(无模板时回退 '?')。"""
|
|
74
|
+
return node.template.name if node.template is not None else '?'
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _loc_prefix(node: StdObject) -> str:
|
|
78
|
+
"""节点在配置中的位置前缀(``[文件:行:列] ``);无来源(合成值,如 !var 引用)返回空。"""
|
|
79
|
+
src = node.source
|
|
80
|
+
if src is None:
|
|
81
|
+
return ''
|
|
82
|
+
return f'[{src.file.name}:{src.start.line}:{src.start.col}] '
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _field(node: StdObject, name: str) -> StdValue:
|
|
86
|
+
"""取节点字段值;缺失/无值抛 BuildError。"""
|
|
87
|
+
for f in node.fields:
|
|
88
|
+
if f.name == name:
|
|
89
|
+
if f.value is None:
|
|
90
|
+
raise BuildError(f'节点 {_tpl_name(node)} 字段 {name!r} 无值')
|
|
91
|
+
return f.value
|
|
92
|
+
raise BuildError(f'节点 {_tpl_name(node)} 缺少字段 {name!r}')
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _impl_source(node: StdObject) -> str:
|
|
96
|
+
"""取节点自带 _impl 源码(校验为 str)。"""
|
|
97
|
+
src = _field(node, IMPL_FIELD)
|
|
98
|
+
if not isinstance(src, StdLiteral) or src.kind != 'str':
|
|
99
|
+
raise BuildError(f'节点 {_tpl_name(node)} 的 _impl 必须是字符串')
|
|
100
|
+
return str(src.value)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _impl_func(source: str, tpl_name: str) -> Any:
|
|
104
|
+
"""编译 _impl 源码 → 可调用函数(约定定义名为 ``impl``)。"""
|
|
105
|
+
ns: dict[str, Any] = {'Any': Any}
|
|
106
|
+
exec(compile(source, f'<impl:{tpl_name}>', 'exec'), ns)
|
|
107
|
+
fn = ns.get('impl')
|
|
108
|
+
if fn is None:
|
|
109
|
+
raise BuildError(f"节点 {tpl_name} 的 _impl 未定义函数 'impl'")
|
|
110
|
+
return fn
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _hashable(v: Any) -> Any:
|
|
114
|
+
"""字面量值 → 稳定可哈希表示(Decimal → str)。"""
|
|
115
|
+
return str(v) if isinstance(v, Decimal) else v
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _std_fingerprint(value: StdValue | None) -> tuple[Any, ...]:
|
|
119
|
+
"""StdValue 的稳定内容指纹(子图 hash 去重的输入部分)。
|
|
120
|
+
|
|
121
|
+
往返一致性:StdObject 排除 noexist / None 字段 —— noexist 不参与构建语义,
|
|
122
|
+
新鲜态与缓存加载态(emit 往返后仍保留 noexist 标记)指纹保持一致,
|
|
123
|
+
避免冷启动后下游节点 hash 逐层漂移(缓存永远不中)。
|
|
124
|
+
"""
|
|
125
|
+
if value is None:
|
|
126
|
+
return ('none',)
|
|
127
|
+
if isinstance(value, StdLiteral):
|
|
128
|
+
return ('lit', value.kind, _hashable(value.value))
|
|
129
|
+
if isinstance(value, StdArray):
|
|
130
|
+
return ('arr', tuple(_std_fingerprint(e) for e in value.elements))
|
|
131
|
+
# 此处 value 已收窄为 StdObject
|
|
132
|
+
return (
|
|
133
|
+
'obj',
|
|
134
|
+
tuple(
|
|
135
|
+
sorted(
|
|
136
|
+
(f.name, _std_fingerprint(f.value)) for f in value.fields if f.value is not None and not f.is_noexist
|
|
137
|
+
)
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _key_hash(key: tuple[Any, ...]) -> str:
|
|
143
|
+
"""子图 key(模板名 + 实现 + 输入指纹)→ 稳定 hash(缓存目录名)。"""
|
|
144
|
+
return hashlib.sha256(repr(key).encode()).hexdigest()[:16]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _node_inputs_key(merged: dict[str, StdValue]) -> tuple[Any, ...]:
|
|
148
|
+
"""物化输入 → 排序后的稳定指纹(字段名无关顺序)。"""
|
|
149
|
+
return tuple(sorted((name, _std_fingerprint(v)) for name, v in merged.items()))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Executor:
|
|
153
|
+
"""操作树执行器:子图 hash 去重 + VFS 签名注入。
|
|
154
|
+
|
|
155
|
+
- **子图去重**:以(模板名 + _impl 源码 + 物化输入指纹)为 key,相同子图只求值一次
|
|
156
|
+
- **VFS 注入**:impl 签名含 ``vfs`` 参数时注入工作区 VFS(否则不注入)
|
|
157
|
+
- **无 dataclass 注入**:impl 参数即降维后的普通 Python 值(dict / list / 标量)
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def __init__(self, vfs: VFS) -> None:
|
|
161
|
+
self._vfs = vfs
|
|
162
|
+
self._memo: dict[tuple[Any, ...], StdValue] = {}
|
|
163
|
+
|
|
164
|
+
def execute(self, node: StdValue) -> StdValue:
|
|
165
|
+
"""递归执行操作树,返回物化节点(原生 StdValue 域)。"""
|
|
166
|
+
if isinstance(node, StdLiteral):
|
|
167
|
+
return node
|
|
168
|
+
if isinstance(node, StdArray):
|
|
169
|
+
return StdArray(elements=[self.execute(e) for e in node.elements])
|
|
170
|
+
|
|
171
|
+
# 1) 子节点先物化;_impl / _gen / _cache 是元数据字段,不进记录
|
|
172
|
+
merged: dict[str, StdValue] = {}
|
|
173
|
+
for f in node.fields:
|
|
174
|
+
if f.value is None or f.name in (IMPL_FIELD, GEN_FIELD, CACHE_FIELD):
|
|
175
|
+
continue
|
|
176
|
+
merged[f.name] = self.execute(f.value)
|
|
177
|
+
|
|
178
|
+
if not any(f.name == IMPL_FIELD for f in node.fields):
|
|
179
|
+
return StdObject(fields=[StdField(name=n, value=v) for n, v in merged.items()])
|
|
180
|
+
|
|
181
|
+
# 2) 子图 hash:相同(模板 + 实现 + 输入)只求值一次
|
|
182
|
+
impl_src = _impl_source(node)
|
|
183
|
+
key = (_tpl_name(node), impl_src, _node_inputs_key(merged))
|
|
184
|
+
node_hash = _key_hash(key) # 复用子图 hash 做缓存指纹(同构同缓存)
|
|
185
|
+
cached = self._memo.get(key)
|
|
186
|
+
if cached is not None:
|
|
187
|
+
return cached
|
|
188
|
+
|
|
189
|
+
# 2b) 持久化节点缓存:<node_hash>.json 命中 → 加载物化结果,跳过 impl
|
|
190
|
+
# 节点默认可缓存;声明 ``_cache: bool = false``(如 ~File 内容检测)→ 每次执行
|
|
191
|
+
if self._node_cacheable(node):
|
|
192
|
+
disk = self._load_node_cache(node_hash)
|
|
193
|
+
if disk is not None:
|
|
194
|
+
self._memo[key] = disk
|
|
195
|
+
return disk
|
|
196
|
+
|
|
197
|
+
# 3) 调用 impl:字段降维为 Python 实参(std_to_python 忠实转换,noexist 丢弃);
|
|
198
|
+
# 签名含 vfs 则注入节点作用域代理
|
|
199
|
+
# 失败(BuildError / impl 内部异常)→ 附加节点配置位置(stdvalue.source)
|
|
200
|
+
try:
|
|
201
|
+
fn = _impl_func(impl_src, _tpl_name(node))
|
|
202
|
+
args = {name: std_to_python(v, keep_noexist=False) for name, v in merged.items()}
|
|
203
|
+
result_std = python_to_std(self._call(fn, args, node_hash))
|
|
204
|
+
except BuildError as e:
|
|
205
|
+
raise BuildError(f'{_loc_prefix(node)}{e}') from e
|
|
206
|
+
except Exception as e:
|
|
207
|
+
raise BuildError(f'{_loc_prefix(node)}{type(e).__name__}: {e}') from e
|
|
208
|
+
|
|
209
|
+
# 4) 合并覆盖:impl 返回的 dict 优先于输入
|
|
210
|
+
if isinstance(result_std, StdObject):
|
|
211
|
+
for rf in result_std.fields:
|
|
212
|
+
if rf.value is not None:
|
|
213
|
+
merged[rf.name] = rf.value
|
|
214
|
+
record: StdValue = StdObject(fields=[StdField(name=n, value=v) for n, v in merged.items()])
|
|
215
|
+
else:
|
|
216
|
+
record = result_std
|
|
217
|
+
# 4b) 落盘节点缓存(``_cache: bool = false`` 声明关闭的节点不落盘)
|
|
218
|
+
if self._node_cacheable(node):
|
|
219
|
+
self._store_node_cache(node_hash, record)
|
|
220
|
+
self._memo[key] = record
|
|
221
|
+
return record
|
|
222
|
+
|
|
223
|
+
def _node_cacheable(self, node: StdObject) -> bool:
|
|
224
|
+
"""节点是否参与持久化缓存:默认 true;模板可声明 ``_cache: bool = false`` 关闭。"""
|
|
225
|
+
for f in node.fields:
|
|
226
|
+
if f.name == CACHE_FIELD:
|
|
227
|
+
if isinstance(f.value, StdLiteral):
|
|
228
|
+
return bool(f.value.value)
|
|
229
|
+
return True
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
def _node_cache_dir(self) -> Path:
|
|
233
|
+
"""持久化节点缓存目录(<工作区>/node_cache)。"""
|
|
234
|
+
p = self._vfs.root / 'node_cache'
|
|
235
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
return p
|
|
237
|
+
|
|
238
|
+
def _load_node_cache(self, node_hash: str) -> StdValue | None:
|
|
239
|
+
"""按节点 hash 加载持久化物化结果(<node_hash>.json);缺失/损坏 → None。
|
|
240
|
+
|
|
241
|
+
上游 3.x:emit 层 ``load_from_json`` 还原自描述标记(Decimal / path / noexist),
|
|
242
|
+
再经 ``python_to_std`` 回 StdValue —— 与 ``dump_to_json`` 无损闭环。
|
|
243
|
+
"""
|
|
244
|
+
path = self._node_cache_dir() / f'{node_hash}.json'
|
|
245
|
+
if not path.exists():
|
|
246
|
+
return None
|
|
247
|
+
try:
|
|
248
|
+
return python_to_std(load_from_json(path.read_text(encoding='utf-8')))
|
|
249
|
+
except Exception:
|
|
250
|
+
return None # 损坏缓存 → 重新执行
|
|
251
|
+
|
|
252
|
+
def _store_node_cache(self, node_hash: str, record: StdValue) -> None:
|
|
253
|
+
"""持久化物化结果到 <node_hash>.json(失败不影响构建)。
|
|
254
|
+
|
|
255
|
+
上游 3.x:``dump_to_json`` 默认全语义保留(Decimal / path / noexist 标记编码),
|
|
256
|
+
与 ``load_from_json`` 无损闭环。
|
|
257
|
+
"""
|
|
258
|
+
try:
|
|
259
|
+
path = self._node_cache_dir() / f'{node_hash}.json'
|
|
260
|
+
path.write_text(dump_to_json(record), encoding='utf-8')
|
|
261
|
+
except Exception:
|
|
262
|
+
pass # 缓存写失败 → 本次不缓存,下次重算
|
|
263
|
+
|
|
264
|
+
def _call(self, fn: Any, args: dict[str, Any], node_hash: str) -> Any:
|
|
265
|
+
"""签名扫描:``vfs`` 参数注入节点代理;其余按名直传(无 dataclass 注解注入)。
|
|
266
|
+
|
|
267
|
+
impl 参数即降维后的普通 Python 值(dict / list / 标量 / PurePosixPath)。
|
|
268
|
+
"""
|
|
269
|
+
kwargs: dict[str, Any] = {}
|
|
270
|
+
for name in inspect.signature(fn).parameters:
|
|
271
|
+
if name == VFS_PARAM:
|
|
272
|
+
kwargs[name] = NodeVFS(self._vfs, node_hash)
|
|
273
|
+
elif name in args:
|
|
274
|
+
kwargs[name] = args[name]
|
|
275
|
+
return fn(**kwargs)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def entry_value(result: CompilationResult) -> StdValue:
|
|
279
|
+
"""取顶层入口字段(操作树根)。"""
|
|
280
|
+
root = result.document.root
|
|
281
|
+
for f in root.fields:
|
|
282
|
+
if f.name == ENTRY_FIELD:
|
|
283
|
+
if f.value is None:
|
|
284
|
+
raise BuildError(f'顶层字段 {ENTRY_FIELD!r} 无值')
|
|
285
|
+
return f.value
|
|
286
|
+
raise BuildError(f'顶层缺少入口字段 {ENTRY_FIELD!r}')
|
infinity_make/main.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""infinity_make —— expr 模式构建系统 CLI:infmake.infd 是一棵操作树。
|
|
2
|
+
|
|
3
|
+
本模块只做「编排」:argparse 解析 → 编译(infinity_data)→ 执行操作树 →
|
|
4
|
+
输出物化 JSON。三层拆分:
|
|
5
|
+
|
|
6
|
+
- :mod:`infinity_make.executor`:操作树执行器
|
|
7
|
+
(子图 hash 去重 + VFS 注入 + 持久化节点缓存,见模块 docstring)
|
|
8
|
+
- :mod:`infinity_make.nixenv`:Nix 环境引导
|
|
9
|
+
(env.infd → ``nix develop`` 重入 / ``--run``)
|
|
10
|
+
- 本模块:CLI 薄壳 + 装配
|
|
11
|
+
|
|
12
|
+
与 infd_builder 玩具版的差异(上游 infinity_data 3.x API):
|
|
13
|
+
- 无模板 dataclass 生成(templates.py 类型契约)与自动 dataclass 注解扫描注入
|
|
14
|
+
- 物化输出经 ``infinity_data.emit.to_json`` 投影(path → 字符串);
|
|
15
|
+
节点缓存经 ``dump_to_json`` / ``load_from_json`` 无损往返(见 executor)
|
|
16
|
+
|
|
17
|
+
用法::
|
|
18
|
+
|
|
19
|
+
python -m infinity_make -i infmake.infd -o make_result.json
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from infinity_data import SandboxConfig, load
|
|
28
|
+
from infinity_data.emit import to_json
|
|
29
|
+
|
|
30
|
+
from infinity_make.executor import BuildError, Executor, entry_value
|
|
31
|
+
from infinity_make.nixenv import ENV_FILE, bootstrap, clone_repos, load_spec, run_command
|
|
32
|
+
from infinity_make.vfs import VFS
|
|
33
|
+
|
|
34
|
+
DEFAULT_WORKSPACE = '.builder'
|
|
35
|
+
"""默认 VFS 工作区目录(相对 infmake.infd 所在目录)。"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _reentry_argv(input_path: Path, args: argparse.Namespace) -> list[str]:
|
|
39
|
+
"""Nix 重入 argv:首项为 ``--input``(本 CLI 用选项参数而非位置参数);
|
|
40
|
+
路径参数一律绝对化(nix develop 的 CWD = env.infd 所在目录,相对路径会
|
|
41
|
+
被二次解析而叠加目录,如 examples/yaml_test/examples/yaml_test/result.json)。
|
|
42
|
+
"""
|
|
43
|
+
argv = ['--input', str(input_path)]
|
|
44
|
+
if args.output:
|
|
45
|
+
argv += ['--output', args.output]
|
|
46
|
+
if args.env:
|
|
47
|
+
argv += ['--env', args.env]
|
|
48
|
+
if args.workspace:
|
|
49
|
+
argv += ['--workspace', args.workspace]
|
|
50
|
+
if args.dev:
|
|
51
|
+
argv.append('--dev')
|
|
52
|
+
return argv
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
56
|
+
parser = argparse.ArgumentParser(
|
|
57
|
+
prog='infmake',
|
|
58
|
+
description='基于 infinity data 的 expr 模式构建系统(infmake.infd 是一棵操作树)',
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument('-i', '--input', type=str, default='infmake.infd', help='构建配置')
|
|
61
|
+
parser.add_argument('-o', '--output', type=str, default='make_result.json', help='构建结果位置')
|
|
62
|
+
parser.add_argument('-e', '--env', type=str, default=None, help='Nix 环境声明文件(默认 <input> 同目录 env.infd)')
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
'-w', '--workspace', type=str, default=None, help='VFS 工作区目录(默认 <input> 同目录 .builder)'
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument('--dev', action='store_true', help='开发模式沙盒(full_access:!from / !file / !env 全开)')
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
'--run', metavar='CMD', default=None, help='进入 env.infd 的 Nix 环境运行命令(不构建),如 --run "code ."'
|
|
69
|
+
)
|
|
70
|
+
return parser
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main(argv: list[str] | None = None) -> int:
|
|
74
|
+
"""CLI:编译 → 执行操作树 → 输出物化结果。
|
|
75
|
+
|
|
76
|
+
``--dev``:开发模式沙盒(``SandboxConfig.full_access()``,允许 ``!from``
|
|
77
|
+
模板导入 / ``!file`` / ``!env``,模板可位于项目外);缺省零信任 ``deny_all()``。
|
|
78
|
+
|
|
79
|
+
Nix 环境引导:infmake.infd 同目录存在 ``env.infd`` 时,先以 safe_load 加载
|
|
80
|
+
(纯数据),生成 ``nix develop`` 环境并重入执行(``!env import`` 在环境内可用)。
|
|
81
|
+
"""
|
|
82
|
+
args = _build_parser().parse_args(argv)
|
|
83
|
+
|
|
84
|
+
# 配置/输出/工作区路径先基于调用方 CWD 解析为绝对路径;
|
|
85
|
+
# 执行期相对路径(sources / output)以 infmake.infd 所在目录为基准
|
|
86
|
+
cwd = Path.cwd()
|
|
87
|
+
input_path = (cwd / args.input).resolve()
|
|
88
|
+
out_path = (cwd / args.output).resolve()
|
|
89
|
+
env_path = (cwd / args.env).resolve() if args.env else input_path.parent / ENV_FILE
|
|
90
|
+
workspace = (cwd / args.workspace).resolve() if args.workspace else None
|
|
91
|
+
# 规范化路径参数为绝对路径:Nix 环境重入后 CWD 变为 env.infd 所在目录,
|
|
92
|
+
# 相对路径会被二次解析而叠加目录(如 examples/yaml_test/examples/yaml_test/result.json)
|
|
93
|
+
args.output = str(out_path)
|
|
94
|
+
args.env = str(env_path)
|
|
95
|
+
args.workspace = str(workspace) if workspace else None
|
|
96
|
+
|
|
97
|
+
env_spec = load_spec(env_path)
|
|
98
|
+
|
|
99
|
+
# ── clone:按 env.infd 声明克隆项目到指定位置(锁 tag/commit,幂等)──
|
|
100
|
+
if env_spec is not None and env_spec.clone:
|
|
101
|
+
code = clone_repos(env_spec, env_path)
|
|
102
|
+
if code != 0:
|
|
103
|
+
return code
|
|
104
|
+
|
|
105
|
+
# ── --run:进入 env.infd 的 Nix 环境运行命令(不构建),如 --run "code ." ──
|
|
106
|
+
if args.run is not None:
|
|
107
|
+
if env_spec is None:
|
|
108
|
+
print('错误: --run 需要 env.infd(build 同目录或 --env 指定)', file=sys.stderr)
|
|
109
|
+
return 1
|
|
110
|
+
return run_command(env_spec, env_path, args.run)
|
|
111
|
+
|
|
112
|
+
# ── Nix 环境引导:env.infd(纯数据,safe_load)→ 生成 nix develop 环境 → 重入 ──
|
|
113
|
+
if env_spec is not None:
|
|
114
|
+
inner_args = _reentry_argv(input_path, args)
|
|
115
|
+
code = bootstrap(env_spec, env_path, inner_args)
|
|
116
|
+
if code is not None:
|
|
117
|
+
return code # 重入子进程已完成(或跳过引导),其退出码即结果
|
|
118
|
+
|
|
119
|
+
sandbox = SandboxConfig.full_access() if args.dev else SandboxConfig.deny_all()
|
|
120
|
+
|
|
121
|
+
result = load(input_path, sandbox=sandbox)
|
|
122
|
+
if result.has_errors:
|
|
123
|
+
for d in result.diagnostics:
|
|
124
|
+
loc = d.location or '<root>'
|
|
125
|
+
print(f' {d.severity.name:7s} {loc} [{d.code}] {d.message}', file=sys.stderr)
|
|
126
|
+
print('编译失败,见上方诊断', file=sys.stderr)
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
# 与 InfinityData 导入语义一致:相对路径以配置文件目录为基准
|
|
130
|
+
os.chdir(input_path.parent)
|
|
131
|
+
vfs = VFS(workspace if workspace is not None else input_path.parent / DEFAULT_WORKSPACE)
|
|
132
|
+
vfs.clean_tmp() # 临时目录每次构建从干净区开始(缓存保留)
|
|
133
|
+
try:
|
|
134
|
+
value_std = Executor(vfs).execute(entry_value(result))
|
|
135
|
+
except BuildError as e:
|
|
136
|
+
print(f'构建失败: {e}', file=sys.stderr)
|
|
137
|
+
return 1
|
|
138
|
+
except Exception as e: # impl 内的执行错误(如命令失败)清晰上报
|
|
139
|
+
print(f'构建失败: {type(e).__name__}: {e}', file=sys.stderr)
|
|
140
|
+
return 1
|
|
141
|
+
|
|
142
|
+
# 物化树是 StdValue 原生域,末端用 emit 层 to_json 投影为 JSON(有损:path → 字符串)
|
|
143
|
+
text = to_json(value_std)
|
|
144
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
out_path.write_text(text + '\n', encoding='utf-8')
|
|
146
|
+
print(f'✓ 构建成功,物化结果: {out_path}')
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == '__main__':
|
|
151
|
+
sys.exit(main())
|
infinity_make/nixenv.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Nix 环境引导:env.infd(纯数据)→ ``nix develop`` 环境 → 重入构建 / ``--run``。
|
|
2
|
+
|
|
3
|
+
- :func:`load_spec`:加载 env.infd 并经 schema(schemas/env.inft 模板 EnvSpec)约束
|
|
4
|
+
—— 字段结构 / 类型 / 额外字段全部由上游 infinity_data 校验,不做手动检查
|
|
5
|
+
- :func:`clone_repos`:按 env.infd 的 ``clone`` 声明克隆项目到指定位置(锁 tag/commit,幂等)
|
|
6
|
+
- :func:`bootstrap`:未在生成环境内且存在 env.infd → ``nix develop`` 重入构建
|
|
7
|
+
(impure --expr 路径,或显式 ``flake = {...}`` 的 flake 模式);已在环境内 → None
|
|
8
|
+
- :func:`run_command`:进入 env.infd 的 Nix 环境运行命令(不构建,如 ``code .``)
|
|
9
|
+
|
|
10
|
+
重入通过环境变量 :data:`ENV_MARKER`(= env.infd 指纹)防死循环:指纹匹配 = 已在生成环境内。
|
|
11
|
+
指纹取**语法层**(``parse_source`` → ``Document.canonical()`` 的 sha256 前缀,与上游模板身份同方案)——
|
|
12
|
+
注释/格式/字段顺序变化不改变指纹,只有字段值变化才改变。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import os
|
|
17
|
+
import shlex
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, cast
|
|
24
|
+
|
|
25
|
+
from infinity_data import SandboxConfig, Schema, load
|
|
26
|
+
from infinity_data.frontend import parse_source
|
|
27
|
+
from infinity_data.infra.file import DiskFile
|
|
28
|
+
from infinity_data.infra.path import native_to_posix
|
|
29
|
+
|
|
30
|
+
ENV_FILE = 'env.infd'
|
|
31
|
+
"""Nix 环境声明文件(infmake.infd 同目录,纯数据,schema 约束加载)。"""
|
|
32
|
+
|
|
33
|
+
ENV_MARKER = 'INFMAKE_NIX_ENV'
|
|
34
|
+
"""已进入生成 Nix 环境的标记环境变量(值 = env.infd 指纹)。"""
|
|
35
|
+
|
|
36
|
+
SCHEMA_TEMPLATE = 'EnvSpec'
|
|
37
|
+
"""env.infd 顶层 schema 模板名(见 schemas/env.inft)。"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class CloneSpec:
|
|
42
|
+
"""env.infd 的 ``clone`` 声明:克隆一个 git 项目到指定位置,锁 tag 或 commit。"""
|
|
43
|
+
|
|
44
|
+
url: str
|
|
45
|
+
dest: Path
|
|
46
|
+
tag: str | None = None
|
|
47
|
+
commit: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class EnvSpec:
|
|
52
|
+
"""env.infd 解析结果:Nix 包 + 环境变量 + shell_hook + flake 控制 + clone 声明。"""
|
|
53
|
+
|
|
54
|
+
packages: list[str] = field(default_factory=list[str])
|
|
55
|
+
env: dict[str, str] = field(default_factory=dict[str, str])
|
|
56
|
+
shell_hook: str = ''
|
|
57
|
+
flake: dict[str, Any] | None = None # flake = {...}:显式启用 flake 模式(inputs/system 配置);缺省=impure --expr
|
|
58
|
+
clone: list[CloneSpec] = field(default_factory=list[CloneSpec])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _schema_path() -> Path:
|
|
62
|
+
"""定位随包分发的 env schema 模板(<包>/schemas/env.inft,package-data)。"""
|
|
63
|
+
p = Path(__file__).resolve().parent / 'schemas' / 'env.inft'
|
|
64
|
+
if not p.exists():
|
|
65
|
+
raise FileNotFoundError(f'未找到 {p}(infinity_make 包内 schema)')
|
|
66
|
+
return p
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_spec(env_path: Path) -> EnvSpec | None:
|
|
70
|
+
"""加载 env.infd 并经 schema(包内 schemas/env.inft 模板 EnvSpec)约束;文件不存在 → None。
|
|
71
|
+
|
|
72
|
+
字段结构 / 类型 / 额外字段 / 必填全部由上游校验(strict),失败打印诊断并退出;
|
|
73
|
+
这里只读回**已校验**的值并组装 EnvSpec(schema 不注入默认值,缺失键仍需回退)。
|
|
74
|
+
"""
|
|
75
|
+
if not env_path.exists():
|
|
76
|
+
return None
|
|
77
|
+
schema_path = _schema_path()
|
|
78
|
+
result = load(
|
|
79
|
+
env_path,
|
|
80
|
+
# 沙盒白名单按语言内 POSIX 形式匹配:Windows 上原生路径(D:\...)须转 /d/...
|
|
81
|
+
sandbox=SandboxConfig(allow_templates=[native_to_posix(schema_path)]),
|
|
82
|
+
schema=Schema(template=SCHEMA_TEMPLATE, from_file=str(schema_path)),
|
|
83
|
+
)
|
|
84
|
+
if result.has_errors:
|
|
85
|
+
for d in result.diagnostics:
|
|
86
|
+
loc = d.location or '<root>'
|
|
87
|
+
print(f' {d.severity.name:7s} {loc} [{d.code}] {d.message}', file=sys.stderr)
|
|
88
|
+
print('env.infd 加载失败,见上方诊断', file=sys.stderr)
|
|
89
|
+
raise SystemExit(1)
|
|
90
|
+
value = result.value
|
|
91
|
+
raw_env = value.get('env')
|
|
92
|
+
env: dict[str, str] = {}
|
|
93
|
+
if isinstance(raw_env, dict):
|
|
94
|
+
env = {str(k): str(v) for k, v in cast(dict[str, Any], raw_env).items()}
|
|
95
|
+
# flake 模式开关:显式 `flake = {...}`(inputs/system 配置);缺省 / null = impure --expr
|
|
96
|
+
raw_clone = value.get('clone')
|
|
97
|
+
clones: list[CloneSpec] = []
|
|
98
|
+
if isinstance(raw_clone, list):
|
|
99
|
+
for item in cast(list[Any], raw_clone):
|
|
100
|
+
d = cast(dict[str, Any], item)
|
|
101
|
+
clones.append(
|
|
102
|
+
CloneSpec(
|
|
103
|
+
url=str(d['url']),
|
|
104
|
+
dest=Path(str(d['dest'])),
|
|
105
|
+
tag=str(d['tag']) if 'tag' in d else None,
|
|
106
|
+
commit=str(d['commit']) if 'commit' in d else None,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
return EnvSpec(
|
|
110
|
+
packages=list(value['packages']) if 'packages' in value else [],
|
|
111
|
+
env=env,
|
|
112
|
+
shell_hook=str(value['shell_hook']) if 'shell_hook' in value else '',
|
|
113
|
+
flake=value.get('flake'),
|
|
114
|
+
clone=clones,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _nixpkgs_path() -> str:
|
|
119
|
+
"""解析 <nixpkgs> 频道为 store 路径(纯模式嵌入,避免频道查找;失败返回空串)。"""
|
|
120
|
+
try:
|
|
121
|
+
out = subprocess.run(
|
|
122
|
+
['nix-instantiate', '--find-file', 'nixpkgs'],
|
|
123
|
+
capture_output=True,
|
|
124
|
+
text=True,
|
|
125
|
+
check=True,
|
|
126
|
+
timeout=120,
|
|
127
|
+
)
|
|
128
|
+
p = out.stdout.strip()
|
|
129
|
+
return p if p.startswith('/nix/store/') else ''
|
|
130
|
+
except (OSError, subprocess.SubprocessError):
|
|
131
|
+
return ''
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _nix_expr(spec: EnvSpec, nixpkgs_path: str = '') -> str:
|
|
135
|
+
"""env.infd → ``nix develop --expr`` 表达式(impure 路径)。
|
|
136
|
+
|
|
137
|
+
env 值里的 ``${attr}/path`` 展开为 nix 字符串插值 ``"${pkgs.attr}/path"``。
|
|
138
|
+
"""
|
|
139
|
+
src = nixpkgs_path if nixpkgs_path else '<nixpkgs>'
|
|
140
|
+
return (
|
|
141
|
+
f'{{ pkgs ? import {src} {{ }} }}:\n'
|
|
142
|
+
'pkgs.mkShell {\n'
|
|
143
|
+
f' packages = with pkgs; [ {" ".join(f"pkgs.{p}" for p in spec.packages)} ];\n'
|
|
144
|
+
f' {_env_lines(spec)}\n'
|
|
145
|
+
'}\n'
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _env_lines(spec: EnvSpec) -> str:
|
|
150
|
+
"""env 声明 → nix mkShell 属性(${attr} → ${pkgs.attr} 插值)。"""
|
|
151
|
+
return '\n '.join(f'{k} = "{v.replace("${", "${pkgs.")}";' for k, v in spec.env.items())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _nix_system() -> str:
|
|
155
|
+
"""当前 Nix 系统(如 x86_64-linux)。"""
|
|
156
|
+
try:
|
|
157
|
+
out = subprocess.run(
|
|
158
|
+
['nix', 'eval', '--impure', '--raw', 'builtins.currentSystem'],
|
|
159
|
+
capture_output=True,
|
|
160
|
+
text=True,
|
|
161
|
+
check=True,
|
|
162
|
+
timeout=60,
|
|
163
|
+
)
|
|
164
|
+
return out.stdout.strip() or 'x86_64-linux'
|
|
165
|
+
except (OSError, subprocess.SubprocessError):
|
|
166
|
+
return 'x86_64-linux'
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _flake_content(spec: EnvSpec, system: str, nixpkgs_path: str) -> str:
|
|
170
|
+
"""flake 模式:nixpkgs 作输入(默认 path: 本机源,可经 flake.inputs 配置为
|
|
171
|
+
github:/git+ URL → flake.lock 记录 rev,commit 级锁定),输出 devShell。
|
|
172
|
+
|
|
173
|
+
env.infd 示例:
|
|
174
|
+
flake = { inputs = { nixpkgs = "github:nixos/nixpkgs/nixos-unstable" } }
|
|
175
|
+
缺省 nixpkgs 输入 → path:(内容级锁定,离线可用)。
|
|
176
|
+
"""
|
|
177
|
+
cfg = spec.flake or {}
|
|
178
|
+
inputs: dict[str, str] = {}
|
|
179
|
+
raw_inputs = cfg.get('inputs')
|
|
180
|
+
if isinstance(raw_inputs, dict):
|
|
181
|
+
inputs = {str(k): str(v) for k, v in cast(dict[str, Any], raw_inputs).items()}
|
|
182
|
+
if 'nixpkgs' not in inputs:
|
|
183
|
+
inputs['nixpkgs'] = f'path:{nixpkgs_path}'
|
|
184
|
+
system = str(cfg.get('system') or system)
|
|
185
|
+
# nix 缩进字符串 ''...'' 里 ${ 是插值起始 → 转义为 ''${(bash 的 ${} 原样保留)
|
|
186
|
+
hook = spec.shell_hook.replace('${', "''${")
|
|
187
|
+
inputs_nix = ''.join(f' inputs.{name}.url = "{url}";\n' for name, url in inputs.items())
|
|
188
|
+
return (
|
|
189
|
+
'{\n'
|
|
190
|
+
f'{inputs_nix}'
|
|
191
|
+
# ... 通配:允许 flake.inputs 里额外的自定义输入(非 nixpkgs)
|
|
192
|
+
f' outputs = {{ self, nixpkgs, ... }}: let pkgs = nixpkgs.legacyPackages.{system}; in {{\n'
|
|
193
|
+
f' devShells.{system}.default = pkgs.mkShell {{\n'
|
|
194
|
+
f' packages = with pkgs; [ {" ".join(f"pkgs.{p}" for p in spec.packages)} ];\n'
|
|
195
|
+
f' {_env_lines(spec)}\n'
|
|
196
|
+
f" shellHook = ''\n{hook}\n '';\n"
|
|
197
|
+
' };\n'
|
|
198
|
+
' };\n'
|
|
199
|
+
'}\n'
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _use_flake(spec: EnvSpec) -> bool:
|
|
204
|
+
"""flake 模式判定:显式 ``flake = {...}`` 即启用;缺省走 impure --expr。"""
|
|
205
|
+
return spec.flake is not None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _nix_develop_cmd(spec: EnvSpec, env_path: Path) -> tuple[list[str], bool]:
|
|
209
|
+
"""构建 ``nix develop`` 命令:flake 模式 → 项目目录临时 flake;否则 --impure + --expr。
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
(nix_cmd, flake_on)
|
|
213
|
+
"""
|
|
214
|
+
if not _use_flake(spec):
|
|
215
|
+
return (['nix', 'develop', '--impure', '--expr', _nix_expr(spec)], False)
|
|
216
|
+
# 显式配置了 nixpkgs 输入(如 github: URL)→ 无需本机路径解析(锁 commit)
|
|
217
|
+
cfg_inputs = (spec.flake or {}).get('inputs')
|
|
218
|
+
has_explicit_nixpkgs = isinstance(cfg_inputs, dict) and 'nixpkgs' in cfg_inputs
|
|
219
|
+
nixpkgs_path = ''
|
|
220
|
+
if not has_explicit_nixpkgs:
|
|
221
|
+
nixpkgs_path = _nixpkgs_path()
|
|
222
|
+
if not nixpkgs_path:
|
|
223
|
+
print('警告: nixpkgs 路径解析失败,flake 模式回退 impure', file=sys.stderr)
|
|
224
|
+
return (['nix', 'develop', '--impure', '--expr', _nix_expr(spec)], False)
|
|
225
|
+
proj = env_path.parent
|
|
226
|
+
# 自动检测 git 仓库;非仓库则自动 git init(flake 模式要求)
|
|
227
|
+
in_git = (
|
|
228
|
+
subprocess.run(
|
|
229
|
+
['git', '-C', str(proj), 'rev-parse', '--is-inside-work-tree'],
|
|
230
|
+
capture_output=True,
|
|
231
|
+
text=True,
|
|
232
|
+
check=False,
|
|
233
|
+
).returncode
|
|
234
|
+
== 0
|
|
235
|
+
)
|
|
236
|
+
if not in_git:
|
|
237
|
+
print(f'[env] {proj} 非 git 仓库,自动 git init(flake 模式要求)...')
|
|
238
|
+
subprocess.run(['git', '-C', str(proj), 'init', '-q'], check=False)
|
|
239
|
+
# flake.nix 直接生成在 env.infd 同目录(可见可审计);git add 即可(无需 commit)
|
|
240
|
+
flake_path = proj / 'flake.nix'
|
|
241
|
+
flake_path.write_text(_flake_content(spec, _nix_system(), nixpkgs_path))
|
|
242
|
+
# -f:项目目录可能在 .gitignore 内,仍需 stage 供 nix 识别
|
|
243
|
+
subprocess.run(['git', '-C', str(proj), 'add', '-f', 'flake.nix'], check=False)
|
|
244
|
+
return (['nix', 'develop', str(proj)], True)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _env_fingerprint(env_path: Path) -> str:
|
|
248
|
+
"""env.infd → 语法层指纹:``sha256(Document.canonical())`` 前缀(与上游模板身份同方案)。
|
|
249
|
+
|
|
250
|
+
canonical 是**标准 infd 源码**(无注释/空白,字段有序)——注释/格式/字段顺序
|
|
251
|
+
变化不改变指纹,只有字段值变化才改变 → 重入标记对格式无关,避免格式化
|
|
252
|
+
env.infd 后误判「环境已变」而反复重建。
|
|
253
|
+
|
|
254
|
+
解析失败(语法错误)→ 回退字节级 hash(load_spec 已在上游校验并退出,此处仅兜底)。
|
|
255
|
+
"""
|
|
256
|
+
try:
|
|
257
|
+
doc, collector = parse_source(DiskFile.from_fullpath(env_path))
|
|
258
|
+
if not collector.has_errors:
|
|
259
|
+
return hashlib.sha256(doc.canonical().encode('utf-8')).hexdigest()[:16]
|
|
260
|
+
except OSError:
|
|
261
|
+
pass
|
|
262
|
+
return hashlib.sha256(env_path.read_bytes()).hexdigest()[:16]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _env_reentry(spec: EnvSpec, env_path: Path, inner: list[str]) -> int:
|
|
266
|
+
"""进入 Nix 环境(重入)运行 inner 命令,返回其退出码(CWD = env.infd 目录)。"""
|
|
267
|
+
fingerprint = _env_fingerprint(env_path)
|
|
268
|
+
nix_cmd, flake_on = _nix_develop_cmd(spec, env_path)
|
|
269
|
+
cmd = [*nix_cmd, '--command', 'env', f'{ENV_MARKER}={fingerprint}', *inner]
|
|
270
|
+
print(f'[env] 进入 Nix 环境(env.infd 指纹 {fingerprint},flake 模式={"on" if flake_on else "off"})...')
|
|
271
|
+
return subprocess.run(cmd, cwd=str(env_path.parent)).returncode
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def bootstrap(spec: EnvSpec, env_path: Path, inner_args: list[str]) -> int | None:
|
|
275
|
+
"""若未在生成环境内且 env.infd 存在 → ``nix develop`` 重入构建。
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
inner_args: 重入时执行的 argv(首项为绝对 build 路径,其余为绝对化后的 CLI 参数)
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
重入子进程的退出码;None = 无需引导(已在环境内 / 无 nix)。
|
|
282
|
+
"""
|
|
283
|
+
fingerprint = _env_fingerprint(env_path)
|
|
284
|
+
if os.environ.get(ENV_MARKER) == fingerprint:
|
|
285
|
+
return None # 已在生成环境内
|
|
286
|
+
if shutil.which('nix') is None:
|
|
287
|
+
print('警告: 未找到 nix,跳过环境引导(可复现性下降)', file=sys.stderr)
|
|
288
|
+
return None
|
|
289
|
+
if spec.shell_hook:
|
|
290
|
+
# shell_hook:进入环境后、构建前执行的 bash(含 hook + exec 构建)
|
|
291
|
+
python_cmd = shlex.join([sys.executable, '-m', 'infinity_make', *inner_args])
|
|
292
|
+
reentry = ['bash', '-c', f'{spec.shell_hook}\nexec {python_cmd}']
|
|
293
|
+
else:
|
|
294
|
+
reentry = [sys.executable, '-m', 'infinity_make', *inner_args]
|
|
295
|
+
return _env_reentry(spec, env_path, reentry)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def run_command(spec: EnvSpec, env_path: Path, command: str) -> int:
|
|
299
|
+
"""``--run``:进入 env.infd 定义的 Nix 环境运行命令(不构建)。
|
|
300
|
+
|
|
301
|
+
- 已在环境内 / 无 nix → 直接运行(shell_hook 后接 command)
|
|
302
|
+
- 否则 → ``nix develop`` 重入运行(CWD = env.infd 目录,如 ``code .``)
|
|
303
|
+
"""
|
|
304
|
+
fingerprint = _env_fingerprint(env_path)
|
|
305
|
+
body = f'{spec.shell_hook}\n{command}' if spec.shell_hook else command
|
|
306
|
+
inner = ['bash', '-c', body]
|
|
307
|
+
if os.environ.get(ENV_MARKER) == fingerprint:
|
|
308
|
+
return subprocess.run(inner, cwd=str(env_path.parent)).returncode
|
|
309
|
+
if shutil.which('nix') is None:
|
|
310
|
+
print('警告: 未找到 nix,跳过环境引导(可复现性下降)', file=sys.stderr)
|
|
311
|
+
return subprocess.run(inner, cwd=str(env_path.parent)).returncode
|
|
312
|
+
return _env_reentry(spec, env_path, inner)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ── clone:克隆项目到指定位置(锁 tag/commit) ─────────────────────────────
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _git(dest: Path, *args: str, check: bool = False) -> subprocess.CompletedProcess[str]:
|
|
319
|
+
"""在指定 git 仓库执行命令(``git -C dest ...``),捕获输出。"""
|
|
320
|
+
return subprocess.run(
|
|
321
|
+
['git', '-C', str(dest), *args],
|
|
322
|
+
capture_output=True,
|
|
323
|
+
text=True,
|
|
324
|
+
check=check,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _clone_one(cs: CloneSpec, base: Path) -> int:
|
|
329
|
+
"""克隆单个项目并锁定 tag/commit(幂等,HEAD 校验保证可复现)。
|
|
330
|
+
|
|
331
|
+
- 目标不存在 → ``git clone`` 到目标目录
|
|
332
|
+
- 目标已存在且是 git 仓库 → 远端收敛到声明 URL 并 ``fetch``(URL 变更也能收敛)
|
|
333
|
+
- 目标已存在但不是 git 仓库 → 报错(不覆盖已有目录)
|
|
334
|
+
- 锁 commit 时额外显式拉取该提交(非分支/标签顶端也能检出)
|
|
335
|
+
- 最后 ``checkout --detach`` 锁定引用,并校验 ``HEAD == <lock>^{commit}``
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
0 = 成功;1 = 失败(已打印诊断)
|
|
339
|
+
"""
|
|
340
|
+
dest = (base / cs.dest).resolve()
|
|
341
|
+
lock = cs.commit or cs.tag
|
|
342
|
+
if lock is None:
|
|
343
|
+
print(f'[clone] 错误: {cs.url} 未声明 tag 或 commit', file=sys.stderr)
|
|
344
|
+
return 1
|
|
345
|
+
try:
|
|
346
|
+
if dest.exists():
|
|
347
|
+
if not (dest / '.git').is_dir():
|
|
348
|
+
print(f'[clone] 错误: 目标 {dest} 已存在但不是 git 仓库(不覆盖)', file=sys.stderr)
|
|
349
|
+
return 1
|
|
350
|
+
# 已克隆:远端收敛到声明 URL 后拉取(幂等)
|
|
351
|
+
_git(dest, 'remote', 'set-url', 'origin', cs.url)
|
|
352
|
+
r = _git(dest, 'fetch', '--tags', 'origin')
|
|
353
|
+
else:
|
|
354
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
355
|
+
r = subprocess.run(['git', 'clone', cs.url, str(dest)], capture_output=True, text=True)
|
|
356
|
+
if r.returncode != 0:
|
|
357
|
+
detail = (r.stderr or r.stdout).strip()
|
|
358
|
+
print(f'[clone] 克隆/拉取失败 {cs.url} → {dest}: {detail}', file=sys.stderr)
|
|
359
|
+
return 1
|
|
360
|
+
if cs.commit:
|
|
361
|
+
# 锁 commit:常规 fetch 未覆盖(非分支/标签顶端)时显式拉取该提交
|
|
362
|
+
_git(dest, 'fetch', 'origin', cs.commit)
|
|
363
|
+
r = _git(dest, 'checkout', '--detach', lock)
|
|
364
|
+
if r.returncode != 0:
|
|
365
|
+
print(f'[clone] 检出锁定 {lock} 失败 {cs.url}: {(r.stderr or r.stdout).strip()}', file=sys.stderr)
|
|
366
|
+
return 1
|
|
367
|
+
head = _git(dest, 'rev-parse', 'HEAD').stdout.strip()
|
|
368
|
+
resolved = _git(dest, 'rev-parse', f'{lock}^{{commit}}').stdout.strip()
|
|
369
|
+
if head != resolved:
|
|
370
|
+
print(f'[clone] 锁校验失败 {cs.url}: HEAD={head},锁定 {lock}={resolved}', file=sys.stderr)
|
|
371
|
+
return 1
|
|
372
|
+
print(f'[clone] ✓ {cs.dest.as_posix()} ← {cs.url} @ {lock}({head[:12]})')
|
|
373
|
+
return 0
|
|
374
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
375
|
+
print(f'[clone] 执行失败 {cs.url}: {e}', file=sys.stderr)
|
|
376
|
+
return 1
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def clone_repos(spec: EnvSpec, env_path: Path) -> int:
|
|
380
|
+
"""按 env.infd 的 ``clone`` 声明克隆项目(锁 tag/commit,幂等)。
|
|
381
|
+
|
|
382
|
+
目标目录相对 env.infd 所在目录解析;每个声明逐个克隆,任一失败 → 返回 1
|
|
383
|
+
(其余继续,便于一次性修完所有问题)。无 clone 声明 → 0。
|
|
384
|
+
"""
|
|
385
|
+
if not spec.clone:
|
|
386
|
+
return 0
|
|
387
|
+
if shutil.which('git') is None:
|
|
388
|
+
print('错误: 未找到 git,无法执行 env.infd 的 clone 声明', file=sys.stderr)
|
|
389
|
+
return 1
|
|
390
|
+
base = env_path.parent
|
|
391
|
+
ok = True
|
|
392
|
+
for cs in spec.clone:
|
|
393
|
+
if _clone_one(cs, base) != 0:
|
|
394
|
+
ok = False
|
|
395
|
+
return 0 if ok else 1
|
infinity_make/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
# env.inft —— env.infd 顶层 schema 模板(EnvSpec)
|
|
3
|
+
#
|
|
4
|
+
# nixenv.load_spec 用它经上游 infinity_data schema 机制校验 env.infd:
|
|
5
|
+
# - 字段结构 / 类型 / 额外字段 / 必填全部由上游校验(本文件是唯一事实来源),
|
|
6
|
+
# 不再在 nixenv 里手动 isinstance 检查
|
|
7
|
+
# - packages: Nix 包列表(每项 str)
|
|
8
|
+
# - env: 环境变量 dict
|
|
9
|
+
# - shell_hook: 进入环境后、构建前执行的 bash
|
|
10
|
+
# - flake: 可选;显式 ``flake = {...}`` 启用 flake 模式(inputs/system 配置),
|
|
11
|
+
# 缺省 / null = impure ``nix develop --expr``
|
|
12
|
+
# - clone: 可选;git 项目克隆声明(每项锁 tag 或 commit,二选一)
|
|
13
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
14
|
+
~CloneSpec {
|
|
15
|
+
url: str
|
|
16
|
+
dest: path
|
|
17
|
+
tag: str? = noexist
|
|
18
|
+
commit: str? = noexist
|
|
19
|
+
: <one(has(tag), has(commit))>,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
~EnvSpec {
|
|
23
|
+
packages: <list, each(str)> = []
|
|
24
|
+
env: dict = {}
|
|
25
|
+
shell_hook: str = ""
|
|
26
|
+
flake: dict? = null
|
|
27
|
+
clone: <list, each(CloneSpec)> = []
|
|
28
|
+
}
|
infinity_make/vfs/vfs.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""构建工作区 VFS:分配临时目录 + 按 key 持久缓存。
|
|
2
|
+
|
|
3
|
+
impl 函数签名里声明 ``vfs`` 参数即可获得注入(执行器用 ``inspect.signature`` 扫描)。
|
|
4
|
+
注入的是 :class:`NodeVFS` —— 节点作用域代理,缓存自动以**当前节点 hash 指纹**为 key::
|
|
5
|
+
|
|
6
|
+
def impl(tag, vfs):
|
|
7
|
+
tmp = vfs.tmp("stage") # 一次性临时目录(每次调用新建)
|
|
8
|
+
cache = vfs.cache("repo") # → cache/<节点hash>/repo(免自定义 key,跨运行复用)
|
|
9
|
+
shared = vfs.global_.cache("pcm") # → cache/pcm(全局共享,显式 key)
|
|
10
|
+
return {"tmp_dir": str(tmp), "cache_dir": str(cache)}
|
|
11
|
+
|
|
12
|
+
- ``tmp``:脏副作用 / 暂存用的临时目录,每次调用分配新目录,避免互相污染
|
|
13
|
+
- ``cache(name)``:**节点级**持久缓存,key = 当前节点 hash(模板 + 实现 + 输入指纹)
|
|
14
|
+
- ``global_``:全局 VFS(需要跨节点共享时用,如所有模块共用的 pcm 目录)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import shutil
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class VFS:
|
|
24
|
+
"""构建工作区 VFS。根目录结构:``<root>/tmp``(临时)与 ``<root>/cache``(缓存)。"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, root: Path) -> None:
|
|
27
|
+
self._root = Path(root)
|
|
28
|
+
self._tmp_root = self._root / 'tmp'
|
|
29
|
+
self._cache_root = self._root / 'cache'
|
|
30
|
+
self._counter = 0
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def root(self) -> Path:
|
|
34
|
+
"""VFS 根目录(工作区)。"""
|
|
35
|
+
return self._root
|
|
36
|
+
|
|
37
|
+
def tmp(self, name: str) -> Path:
|
|
38
|
+
"""分配一次性临时目录(每次调用新建,永不复用)。"""
|
|
39
|
+
self._counter += 1
|
|
40
|
+
p = self._tmp_root / f'{self._counter:04d}-{name}'
|
|
41
|
+
p.mkdir(parents=True, exist_ok=False)
|
|
42
|
+
return p
|
|
43
|
+
|
|
44
|
+
def cache(self, key: str, name: str = 'artifact') -> Path:
|
|
45
|
+
"""按 key 持久缓存目录:key 相同 → 返回同一目录(幂等复用)。"""
|
|
46
|
+
p = self._cache_root / key / name
|
|
47
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
return p
|
|
49
|
+
|
|
50
|
+
def clean_tmp(self) -> None:
|
|
51
|
+
"""清空临时目录(保留缓存)。"""
|
|
52
|
+
if self._tmp_root.exists():
|
|
53
|
+
shutil.rmtree(self._tmp_root)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NodeVFS:
|
|
57
|
+
"""节点作用域 VFS 代理:缓存自动以当前节点 hash 指纹为 key。
|
|
58
|
+
|
|
59
|
+
执行器把 :class:`VFS` 包一层注入 ``vfs`` 参数 —— impl 无需自定义缓存 key:
|
|
60
|
+
相同(模板 + 实现 + 输入)的节点 = 相同 hash = 相同缓存目录(跨运行复用)。
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, base: VFS, node_hash: str) -> None:
|
|
64
|
+
self._base = base
|
|
65
|
+
self.node_hash = node_hash
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def global_(self) -> VFS:
|
|
69
|
+
"""全局 VFS(共享缓存 / 临时目录的逃生口)。"""
|
|
70
|
+
return self._base
|
|
71
|
+
|
|
72
|
+
def cache(self, name: str = 'artifact') -> Path:
|
|
73
|
+
"""节点级持久缓存:``<cache>/<节点hash>/<name>``,免自定义 key。"""
|
|
74
|
+
return self._base.cache(f'{self.node_hash}/{name}')
|
|
75
|
+
|
|
76
|
+
def tmp(self, name: str) -> Path:
|
|
77
|
+
"""一次性临时目录(全局,每次构建从干净区开始)。"""
|
|
78
|
+
return self._base.tmp(name)
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: infinity_make
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: 基于 InfinityData 的自定义构建系统
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: infinity_data[tool]>=3.2.0
|
|
8
|
+
|
|
9
|
+
# infinity_make
|
|
10
|
+
|
|
11
|
+
基于 [InfinityData](https://github.com/yinbailiang/infinity_data)(`.infd` 声明式配置语言)的自定义构建系统。
|
|
12
|
+
|
|
13
|
+
`infmake.infd` 是一棵**操作树**:每个节点是一个带内嵌 Python 实现的模板实例。编译 `.infd` → 执行操作树 → 输出物化 JSON 产物,全程带子图去重、VFS 注入与持久化节点缓存。
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv run infmake -i infmake.infd --dev -o make_result.json
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 特性
|
|
20
|
+
|
|
21
|
+
- **操作树构建**:`.infd` 声明构建流程,节点 = 模板 + 数据字段 + 内嵌 `impl`(Python)
|
|
22
|
+
- **子图 hash 去重**:相同(模板 + 实现 + 物化输入)的子图只求值一次,结果复用
|
|
23
|
+
- **持久化节点缓存**:节点物化结果按 hash 落盘(`<workspace>/node_cache/<hash>.json`),跨运行复用
|
|
24
|
+
- **VFS 注入**:`impl` 签名声明 `vfs` 参数即注入构建工作区(临时目录 + 按 key 缓存)
|
|
25
|
+
- **Nix 环境引导**:`env.infd` → 自动 `nix develop` 重入构建(flake / impure 两种模式)
|
|
26
|
+
- **沙盒安全**:默认零信任 `deny_all()`;`--dev` 开发模式 `full_access()`(`!from` / `!file` / `!env` 全开)
|
|
27
|
+
- **可审计产物**:输出为自包含的物化 JSON 树
|
|
28
|
+
|
|
29
|
+
## 安装
|
|
30
|
+
|
|
31
|
+
需要 Python ≥ 3.14。
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# 作为项目依赖(uv 本地路径 / 已发布包)
|
|
35
|
+
uv add --editable /path/to/infinity_make
|
|
36
|
+
|
|
37
|
+
# 或直接在本仓库安装
|
|
38
|
+
uv sync
|
|
39
|
+
uv run infmake --help
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
安装后提供 `infmake` 命令(`python -m infinity_make` 亦可)。
|
|
43
|
+
|
|
44
|
+
## 快速开始
|
|
45
|
+
|
|
46
|
+
一个最小示例,三件套:模板文件 → 构建配置 → 运行。
|
|
47
|
+
|
|
48
|
+
**1. 模板** `templates/hello.inft`(`.inft` = 仅模板定义的源文件):
|
|
49
|
+
|
|
50
|
+
```infd
|
|
51
|
+
~Hello {
|
|
52
|
+
name: str = "world"
|
|
53
|
+
|
|
54
|
+
_gen: dict = {
|
|
55
|
+
message: str = ""
|
|
56
|
+
}
|
|
57
|
+
_impl: str = ```python
|
|
58
|
+
def impl(name: str) -> dict[str, str]:
|
|
59
|
+
return {"message": f"hello, {name}!"}
|
|
60
|
+
```
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- `_gen`:声明 `impl` 产出的字段(元数据,不进物化记录)
|
|
65
|
+
- `_impl`:内嵌 Python 源码(三重反引号多行字符串),约定定义名为 `impl` 的函数
|
|
66
|
+
- `impl` 收到的参数是**降维后的普通 Python 值**(`str` / `int` / `list` / `dict` / `PurePosixPath`),返回的 `dict` 合并回节点记录
|
|
67
|
+
|
|
68
|
+
**2. 构建配置** `infmake.infd`:
|
|
69
|
+
|
|
70
|
+
```infd
|
|
71
|
+
!from p"templates/hello.inft" import Hello
|
|
72
|
+
|
|
73
|
+
!var Hello(name = "world") import . as hello
|
|
74
|
+
|
|
75
|
+
target = $hello
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- `!from ... import`:导入模板
|
|
79
|
+
- `!var ... import . as ...`:定义变量(节点实例)
|
|
80
|
+
- `target`:顶层入口字段,即操作树根
|
|
81
|
+
|
|
82
|
+
**3. 运行**:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
uv run infmake -i infmake.infd --dev -o make_result.json
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
产物 `make_result.json`:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"name": "world",
|
|
93
|
+
"message": "hello, world!"
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
> 使用了 `!from` / `!file` / `!env` 的配置必须加 `--dev`(默认沙盒为零信任 `deny_all()`,禁止导入)。
|
|
98
|
+
|
|
99
|
+
## 命令行
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
usage: infmake [-h] [-i INPUT] [-o OUTPUT] [-e ENV] [-w WORKSPACE] [--dev] [--run CMD]
|
|
103
|
+
|
|
104
|
+
options:
|
|
105
|
+
-h, --help 显示帮助
|
|
106
|
+
-i, --input INPUT 构建配置(默认 infmake.infd)
|
|
107
|
+
-o, --output OUTPUT 构建结果位置(默认 make_result.json)
|
|
108
|
+
-e, --env ENV Nix 环境声明文件(默认 <input> 同目录 env.infd)
|
|
109
|
+
-w, --workspace WORKSPACE
|
|
110
|
+
VFS 工作区目录(默认 <input> 同目录 .builder)
|
|
111
|
+
--dev 开发模式沙盒(full_access:!from / !file / !env 全开)
|
|
112
|
+
--run CMD 进入 env.infd 的 Nix 环境运行命令(不构建),如 --run "code ."
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
执行流程:加载 `env.infd`(存在则引导 Nix 环境重入)→ 编译 `infmake.infd` → 执行操作树 → 输出物化 JSON。
|
|
116
|
+
|
|
117
|
+
## Nix 环境(env.infd)
|
|
118
|
+
|
|
119
|
+
`infmake.infd` 同目录放置 `env.infd`(纯数据,经包内 `schemas/env.inft` 的 `EnvSpec` 模板严格校验)后,构建会自动 `nix develop` 重入,环境内 `!env import` 可用:
|
|
120
|
+
|
|
121
|
+
```infd
|
|
122
|
+
packages = [
|
|
123
|
+
"clang",
|
|
124
|
+
"cmake",
|
|
125
|
+
"ninja",
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
env = {
|
|
129
|
+
CMAKE_PREFIX_PATH = "${cmake}",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
shell_hook = ```bash
|
|
133
|
+
export NIX_HARDENING_ENABLE="pic relro"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
flake = {
|
|
137
|
+
inputs = { nixpkgs = "github:nixos/nixpkgs/nixos-26.05" }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
clone = [
|
|
141
|
+
{
|
|
142
|
+
url = "https://github.com/foo/bar.git"
|
|
143
|
+
dest = p"deps/bar"
|
|
144
|
+
tag = "v1.2.3"
|
|
145
|
+
}
|
|
146
|
+
{
|
|
147
|
+
url = "git@github.com:foo/baz.git"
|
|
148
|
+
dest = p"src/baz"
|
|
149
|
+
commit = "3f2a1c9d8b7e"
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
- `packages`:Nix 包列表
|
|
155
|
+
- `env`:环境变量,`${attr}` 自动展开为 nix 字符串插值 `${pkgs.attr}`
|
|
156
|
+
- `shell_hook`:进入环境后、构建前执行的 bash(再 `exec` 构建)
|
|
157
|
+
- `flake = {...}`:显式启用 **flake 模式**(自动生成 `flake.nix` + `flake.lock`,commit 级锁定 nixpkgs);缺省 = impure `nix develop --expr`
|
|
158
|
+
- `clone`:可选;**git 项目克隆声明**,在构建/进入环境前把项目克隆到指定位置(相对 `env.infd` 所在目录)。每项必须**锁 tag 或 commit(二选一)**,克隆后校验 `HEAD == 锁定提交` 保证可复现:
|
|
159
|
+
- `url`:git 仓库地址(必填)
|
|
160
|
+
- `dest`:目标目录(`p"..."` 路径字面量)
|
|
161
|
+
- `tag`:锁定 tag
|
|
162
|
+
- `commit`:锁定 commit(非分支/标签顶端的提交也会显式拉取)
|
|
163
|
+
- 幂等:目标已存在且是 git 仓库时收敛远端并拉取、检出锁定;已存在但非 git 仓库则报错不覆盖
|
|
164
|
+
|
|
165
|
+
重入通过环境变量 `INFMAKE_NIX_ENV`(= env.infd 语法层指纹)防死循环;`env.infd` 内容变化才触发重建。
|
|
166
|
+
|
|
167
|
+
`--run` 可只进入该环境运行命令而不构建:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
uv run infmake --run "code ." # 在 Nix 环境里打开 VS Code
|
|
171
|
+
uv run infmake --run "bash gen_pkgconfig.sh"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## 进阶用法
|
|
175
|
+
|
|
176
|
+
### 模板与实现外置
|
|
177
|
+
|
|
178
|
+
实现代码可外置到 `.py` 文件,用 `!file ... as raw` 导入为字符串(模板内再引用):
|
|
179
|
+
|
|
180
|
+
```infd
|
|
181
|
+
# core.inft
|
|
182
|
+
!file p"impl/compile.py" as raw import . as CompileImpl
|
|
183
|
+
|
|
184
|
+
~Compile {
|
|
185
|
+
compiler: Compiler
|
|
186
|
+
source: File
|
|
187
|
+
flags: <list, each(str)> = []
|
|
188
|
+
_gen: dict = {
|
|
189
|
+
object: str = "",
|
|
190
|
+
compile_command: object = {},
|
|
191
|
+
}
|
|
192
|
+
_impl: str = $CompileImpl
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
# impl/compile.py
|
|
198
|
+
def impl(compiler: dict, source: dict, flags: list[str], vfs) -> dict:
|
|
199
|
+
...
|
|
200
|
+
return {"object": obj, "compile_command": cmd}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### 内容快照节点(~File)
|
|
204
|
+
|
|
205
|
+
`path` 类型的字段(`p"src"`)由 `impl` 收到 `PurePosixPath`;若要**文件内容**参与节点 hash(内容变化 → 下游缓存自动失效),用 `~File` 节点对文件/目录算内容指纹,下游 `$file` 引用它即可(指纹自动传播进下游节点 key):
|
|
206
|
+
|
|
207
|
+
```infd
|
|
208
|
+
~File {
|
|
209
|
+
path: path
|
|
210
|
+
ignore: <list, each(str)> = []
|
|
211
|
+
mtime: bool = false # mtime 快照加速
|
|
212
|
+
_cache: bool = false # 内容检测节点:每次执行重读
|
|
213
|
+
_gen: dict = {
|
|
214
|
+
path: str = "",
|
|
215
|
+
hash: str = "",
|
|
216
|
+
entries: int = 0,
|
|
217
|
+
exists: bool = false,
|
|
218
|
+
}
|
|
219
|
+
_impl: str = ```python
|
|
220
|
+
def impl(path: str, ignore: list[str], mtime: bool, vfs) -> dict:
|
|
221
|
+
... # 递归内容指纹(sha256)
|
|
222
|
+
```
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### VFS 注入
|
|
227
|
+
|
|
228
|
+
`impl` 签名含 `vfs` 参数即注入节点作用域代理(缓存自动以当前节点 hash 为 key):
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
def impl(tag, vfs):
|
|
232
|
+
tmp = vfs.tmp("stage") # 一次性临时目录(每次调用新建)
|
|
233
|
+
cache = vfs.cache("repo") # → cache/<节点hash>/repo(跨运行复用)
|
|
234
|
+
shared = vfs.global_.cache("pcm") # → cache/pcm(全局共享)
|
|
235
|
+
return {"tmp_dir": str(tmp), "cache_dir": str(cache)}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### 节点缓存
|
|
239
|
+
|
|
240
|
+
- 默认开启:节点物化结果经 `dump_to_json` / `load_from_json` 无损往返落盘 `<workspace>/node_cache/<hash>.json`(`Decimal` / `path` / `noexist` 以自描述标记编码)
|
|
241
|
+
- 模板声明 `_cache: bool = false` 关闭(如 `~File` 内容检测)
|
|
242
|
+
|
|
243
|
+
## 项目结构
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
src/infinity_make/
|
|
247
|
+
__init__.py 公共 API(Executor / BuildError / main)
|
|
248
|
+
__main__.py python -m infinity_make 入口
|
|
249
|
+
main.py CLI 薄壳:argparse → 编译 → 执行 → 输出 JSON
|
|
250
|
+
executor.py 操作树执行器(子图 hash 去重 + VFS 注入 + 节点缓存)
|
|
251
|
+
nixenv.py Nix 环境引导(env.infd → nix develop 重入 / --run)
|
|
252
|
+
vfs/ 构建工作区 VFS(tmp + cache + node_cache)
|
|
253
|
+
schemas/env.inft env.infd 的 EnvSpec schema(包内分发)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
工作区(默认 `<input> 同目录 .builder/`):
|
|
257
|
+
|
|
258
|
+
```
|
|
259
|
+
.builder/
|
|
260
|
+
tmp/ 一次性临时目录(每次构建从干净区开始,缓存保留)
|
|
261
|
+
cache/ VFS 缓存(<key>/<name>,跨运行复用)
|
|
262
|
+
node_cache/ 节点物化缓存(<hash>.json)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## 开发
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
uv sync --group dev # ruff / pyright / pre-commit / pytest
|
|
269
|
+
uv run ruff check .
|
|
270
|
+
uv run pyright
|
|
271
|
+
uv run pytest
|
|
272
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
infinity_make/__init__.py,sha256=gxlRbdpfoydN1Ikzf5XiglGsnM0URJjy3hZcv_FbRsQ,682
|
|
2
|
+
infinity_make/__main__.py,sha256=RfS_NdBDqjr_i0Gzssy5RwEgvG3yj1kAQY8pl6dxGrA,173
|
|
3
|
+
infinity_make/executor.py,sha256=_hffSmvA7YrKO1uMasRlubgUsDmtm1diETB2H-b-NMQ,12042
|
|
4
|
+
infinity_make/main.py,sha256=NndMD4hGU4rECdZlxetvyEKUhEvc2Jb8XkcPV1U1Kkg,6868
|
|
5
|
+
infinity_make/nixenv.py,sha256=cis5BYDoIuFimJQ64F_57B17FktAadSd0EwUVnKaWoY,17408
|
|
6
|
+
infinity_make/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
infinity_make/schemas/env.inft,sha256=zTa2uQ9tSnCUS7eSyeu_bFIq1nkw2Xlh71a-inzMyOk,1453
|
|
8
|
+
infinity_make/vfs/__init__.py,sha256=4G4CL7HDuDjuPMf4loMBCICJeHSDC704NLy4wZFd-Mo,60
|
|
9
|
+
infinity_make/vfs/vfs.py,sha256=dslo5S3pQNxdmG_rSbTxl2CPyB5VA5-y6N_b2Zs_JN0,3152
|
|
10
|
+
infinity_make-1.0.0.dist-info/METADATA,sha256=D_NY-_y83JBYtvQd9CJGHJpAuL-PIh8o0PHCcROvi3g,8990
|
|
11
|
+
infinity_make-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
infinity_make-1.0.0.dist-info/entry_points.txt,sha256=_0x9OG4yRjt8cdvncFjDePEUWIs7BnNV9iM5Z6FFMog,52
|
|
13
|
+
infinity_make-1.0.0.dist-info/top_level.txt,sha256=ERrn1vIXEBisoXzvsuGG70S_J45PIlYA78KoHYe2R44,14
|
|
14
|
+
infinity_make-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
infinity_make
|