sutra-dev 0.2.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.
- sutra_compiler/__init__.py +49 -0
- sutra_compiler/__main__.py +514 -0
- sutra_compiler/ast_nodes.py +553 -0
- sutra_compiler/codegen.py +1811 -0
- sutra_compiler/codegen_base.py +2436 -0
- sutra_compiler/codegen_pytorch.py +1472 -0
- sutra_compiler/diagnostics.py +145 -0
- sutra_compiler/inliner.py +581 -0
- sutra_compiler/lexer.py +821 -0
- sutra_compiler/parser.py +2112 -0
- sutra_compiler/review.py +322 -0
- sutra_compiler/simplify.py +1046 -0
- sutra_compiler/simplify_egglog.py +674 -0
- sutra_compiler/stdlib/axons.su +53 -0
- sutra_compiler/stdlib/embed.su +48 -0
- sutra_compiler/stdlib/javascript_object.su +18 -0
- sutra_compiler/stdlib/logic.su +202 -0
- sutra_compiler/stdlib/math.su +12 -0
- sutra_compiler/stdlib/memory.su +82 -0
- sutra_compiler/stdlib/numbers.su +99 -0
- sutra_compiler/stdlib/rotation.su +83 -0
- sutra_compiler/stdlib/similarity.su +97 -0
- sutra_compiler/stdlib/strings.su +56 -0
- sutra_compiler/stdlib/tensor.su +82 -0
- sutra_compiler/stdlib/vectors.su +119 -0
- sutra_compiler/stdlib_loader.py +219 -0
- sutra_compiler/sutradb_embedded.py +273 -0
- sutra_compiler/trace.py +135 -0
- sutra_compiler/validator.py +552 -0
- sutra_compiler/workspace.py +655 -0
- sutra_dev-0.2.0.dist-info/METADATA +80 -0
- sutra_dev-0.2.0.dist-info/RECORD +36 -0
- sutra_dev-0.2.0.dist-info/WHEEL +5 -0
- sutra_dev-0.2.0.dist-info/entry_points.txt +2 -0
- sutra_dev-0.2.0.dist-info/licenses/LICENSE +201 -0
- sutra_dev-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
"""Workspace and project loader for the Sutra compiler.
|
|
2
|
+
|
|
3
|
+
Reads `atman.toml` files. A workspace has a root `atman.toml` with a
|
|
4
|
+
`[workspace]` table that lists member projects; each member project
|
|
5
|
+
has its own `atman.toml` at its directory root with a `[project]`
|
|
6
|
+
table. The name `atman.toml` is fixed by convention — the language
|
|
7
|
+
runtime and every IDE integration looks for exactly that filename at
|
|
8
|
+
the top of a directory, so no `*.aksln` / `*.akproj` discovery heuristic
|
|
9
|
+
is needed.
|
|
10
|
+
|
|
11
|
+
Formal schema: `planning/sutra-spec/22-workspaces.md`. This file is the
|
|
12
|
+
reference Python implementation the schema describes — it is the source
|
|
13
|
+
of truth for "what does a well-formed `atman.toml` mean," and every
|
|
14
|
+
other tool (the IntelliJ plugin's Kotlin data model, the VS Code
|
|
15
|
+
extension, the website docs) is expected to match it.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from sutra_compiler.workspace import load_workspace
|
|
21
|
+
|
|
22
|
+
ws = load_workspace(Path("atman.toml"))
|
|
23
|
+
for project in ws.projects_in_build_order:
|
|
24
|
+
print(project.name, project.substrate, project.sources)
|
|
25
|
+
|
|
26
|
+
Errors are raised as `WorkspaceError` with a stable `SUT####` code in
|
|
27
|
+
the `SUT2000-SUT2099` range reserved for workspace-model errors. The
|
|
28
|
+
same codes carry over from the old `.aksln`/`.akproj` era; call sites
|
|
29
|
+
that check `err.code` do not need to change.
|
|
30
|
+
|
|
31
|
+
Design notes:
|
|
32
|
+
|
|
33
|
+
- TOML parsing uses the Python 3.11+ standard library `tomllib` module.
|
|
34
|
+
No third-party dependency.
|
|
35
|
+
- Filesystem paths inside TOML are always resolved relative to the
|
|
36
|
+
`atman.toml` that contains them.
|
|
37
|
+
- Glob expansion uses `pathlib.Path.glob` with `**` support.
|
|
38
|
+
- Dependency resolution uses a three-pass approach: parse all member
|
|
39
|
+
projects, build the edge set, then topologically sort with a cycle
|
|
40
|
+
detector that reports the exact cycle on error.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import re
|
|
46
|
+
import tomllib
|
|
47
|
+
from dataclasses import dataclass, field
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from typing import Any, Iterable
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ============================================================
|
|
53
|
+
# Error type
|
|
54
|
+
# ============================================================
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class WorkspaceError(Exception):
|
|
58
|
+
"""Raised for any invalid `atman.toml` workspace or project file.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
code: The `SUT####` diagnostic code from the spec (§Error reporting).
|
|
62
|
+
message: Human-readable one-line summary.
|
|
63
|
+
details: Optional structured payload.
|
|
64
|
+
source_path: The file that produced the error, if applicable.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
code: str,
|
|
70
|
+
message: str,
|
|
71
|
+
*,
|
|
72
|
+
details: Any | None = None,
|
|
73
|
+
source_path: Path | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.code = code
|
|
76
|
+
self.message = message
|
|
77
|
+
self.details = details
|
|
78
|
+
self.source_path = source_path
|
|
79
|
+
suffix = f" ({source_path})" if source_path else ""
|
|
80
|
+
super().__init__(f"{code}: {message}{suffix}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ============================================================
|
|
84
|
+
# Data types
|
|
85
|
+
# ============================================================
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
VALID_SUBSTRATES = frozenset({"silicon", "logit"})
|
|
89
|
+
PROJECT_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_-]*$")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class ProjectDependency:
|
|
94
|
+
"""One inter-project edge declared in a project `atman.toml`."""
|
|
95
|
+
|
|
96
|
+
name: str
|
|
97
|
+
path: Path # absolute, resolved
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class Project:
|
|
102
|
+
"""One project within a workspace, fully resolved and validated."""
|
|
103
|
+
|
|
104
|
+
name: str
|
|
105
|
+
path: Path # absolute path to the project directory
|
|
106
|
+
atman_file: Path # absolute path to the project's `atman.toml`
|
|
107
|
+
entry: Path # absolute path to the entry-point `.su` file
|
|
108
|
+
substrate: str # one of VALID_SUBSTRATES
|
|
109
|
+
description: str
|
|
110
|
+
compiler_args: list[str]
|
|
111
|
+
sources: list[Path] # absolute paths, expanded from the globs
|
|
112
|
+
dependencies: list[ProjectDependency]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class Workspace:
|
|
117
|
+
"""One workspace, fully resolved and validated."""
|
|
118
|
+
|
|
119
|
+
name: str
|
|
120
|
+
sutra_version: str
|
|
121
|
+
description: str
|
|
122
|
+
default_substrate: str
|
|
123
|
+
compiler_args: list[str]
|
|
124
|
+
atman_file: Path # absolute path to the workspace `atman.toml`
|
|
125
|
+
projects: list[Project] # in topological (build) order
|
|
126
|
+
projects_by_name: dict[str, Project] = field(default_factory=dict)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def projects_in_build_order(self) -> list[Project]:
|
|
130
|
+
"""Alias for `projects`; kept for readability at call sites."""
|
|
131
|
+
return self.projects
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ============================================================
|
|
135
|
+
# TOML helpers
|
|
136
|
+
# ============================================================
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _read_toml(path: Path, *, is_workspace: bool) -> dict[str, Any]:
|
|
140
|
+
"""Read an `atman.toml` file and return its top-level table.
|
|
141
|
+
|
|
142
|
+
`is_workspace` determines which error code to use for malformed
|
|
143
|
+
TOML: SUT2001 for the workspace-level atman.toml, SUT2006 for a
|
|
144
|
+
member project atman.toml.
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
with path.open("rb") as f:
|
|
148
|
+
return tomllib.load(f)
|
|
149
|
+
except tomllib.TOMLDecodeError as e:
|
|
150
|
+
code = "SUT2001" if is_workspace else "SUT2006"
|
|
151
|
+
raise WorkspaceError(
|
|
152
|
+
code, f"file is not valid TOML: {e}", source_path=path
|
|
153
|
+
) from e
|
|
154
|
+
except OSError as e:
|
|
155
|
+
raise WorkspaceError(
|
|
156
|
+
"SUT2004",
|
|
157
|
+
f"cannot open file: {e}",
|
|
158
|
+
source_path=path,
|
|
159
|
+
) from e
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _require_string(
|
|
163
|
+
table: dict[str, Any],
|
|
164
|
+
key: str,
|
|
165
|
+
*,
|
|
166
|
+
code: str,
|
|
167
|
+
source_path: Path,
|
|
168
|
+
) -> str:
|
|
169
|
+
"""Pull a required string field out of a TOML table."""
|
|
170
|
+
value = table.get(key)
|
|
171
|
+
if value is None:
|
|
172
|
+
raise WorkspaceError(
|
|
173
|
+
code,
|
|
174
|
+
f"missing required field `{key}`",
|
|
175
|
+
source_path=source_path,
|
|
176
|
+
)
|
|
177
|
+
if not isinstance(value, str):
|
|
178
|
+
raise WorkspaceError(
|
|
179
|
+
code,
|
|
180
|
+
f"field `{key}` must be a string, got {type(value).__name__}",
|
|
181
|
+
source_path=source_path,
|
|
182
|
+
)
|
|
183
|
+
return value
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ============================================================
|
|
187
|
+
# Workspace loading
|
|
188
|
+
# ============================================================
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def load_workspace(atman_path: Path) -> Workspace:
|
|
192
|
+
"""Load, parse, validate, and resolve a workspace `atman.toml`.
|
|
193
|
+
|
|
194
|
+
This is the main public entry point. It runs every stage of the
|
|
195
|
+
resolution algorithm from §"Resolution algorithm" in the spec:
|
|
196
|
+
TOML parse → schema validate → member discovery → dependency
|
|
197
|
+
graph → topological sort → Workspace object.
|
|
198
|
+
"""
|
|
199
|
+
atman_path = atman_path.resolve()
|
|
200
|
+
if not atman_path.is_file():
|
|
201
|
+
raise WorkspaceError(
|
|
202
|
+
"SUT2004",
|
|
203
|
+
f"workspace file does not exist: {atman_path}",
|
|
204
|
+
source_path=atman_path,
|
|
205
|
+
)
|
|
206
|
+
doc = _read_toml(atman_path, is_workspace=True)
|
|
207
|
+
|
|
208
|
+
workspace_table = doc.get("workspace")
|
|
209
|
+
if not isinstance(workspace_table, dict):
|
|
210
|
+
raise WorkspaceError(
|
|
211
|
+
"SUT2002",
|
|
212
|
+
"workspace file is missing the [workspace] table",
|
|
213
|
+
source_path=atman_path,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
name = _require_string(
|
|
217
|
+
workspace_table, "name", code="SUT2002", source_path=atman_path,
|
|
218
|
+
)
|
|
219
|
+
sutra_version = _require_string(
|
|
220
|
+
workspace_table, "sutra_version",
|
|
221
|
+
code="SUT2002", source_path=atman_path,
|
|
222
|
+
)
|
|
223
|
+
description = workspace_table.get("description", "")
|
|
224
|
+
if not isinstance(description, str):
|
|
225
|
+
raise WorkspaceError(
|
|
226
|
+
"SUT2002",
|
|
227
|
+
"`workspace.description` must be a string",
|
|
228
|
+
source_path=atman_path,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
default_substrate = workspace_table.get("default_substrate", "silicon")
|
|
232
|
+
if default_substrate not in VALID_SUBSTRATES:
|
|
233
|
+
raise WorkspaceError(
|
|
234
|
+
"SUT2014",
|
|
235
|
+
f"unknown `workspace.default_substrate` value `{default_substrate}`; "
|
|
236
|
+
f"must be one of {sorted(VALID_SUBSTRATES)}",
|
|
237
|
+
source_path=atman_path,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
compiler_args = workspace_table.get("compiler_args", [])
|
|
241
|
+
if not isinstance(compiler_args, list) or not all(
|
|
242
|
+
isinstance(a, str) for a in compiler_args
|
|
243
|
+
):
|
|
244
|
+
raise WorkspaceError(
|
|
245
|
+
"SUT2002",
|
|
246
|
+
"`workspace.compiler_args` must be a list of strings",
|
|
247
|
+
source_path=atman_path,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
member_entries = workspace_table.get("member")
|
|
251
|
+
if not isinstance(member_entries, list) or len(member_entries) == 0:
|
|
252
|
+
raise WorkspaceError(
|
|
253
|
+
"SUT2002",
|
|
254
|
+
"workspace file must contain at least one [[workspace.member]] entry",
|
|
255
|
+
source_path=atman_path,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# First pass: discover every member and load its project atman.toml,
|
|
259
|
+
# applying any workspace-level overrides, without yet validating
|
|
260
|
+
# dependencies.
|
|
261
|
+
workspace_dir = atman_path.parent
|
|
262
|
+
projects_unordered: list[Project] = []
|
|
263
|
+
for idx, entry in enumerate(member_entries):
|
|
264
|
+
if not isinstance(entry, dict):
|
|
265
|
+
raise WorkspaceError(
|
|
266
|
+
"SUT2002",
|
|
267
|
+
f"[[workspace.member]] entry #{idx} must be a table",
|
|
268
|
+
source_path=atman_path,
|
|
269
|
+
)
|
|
270
|
+
rel_path = _require_string(
|
|
271
|
+
entry, "path", code="SUT2002", source_path=atman_path,
|
|
272
|
+
)
|
|
273
|
+
project_dir = (workspace_dir / rel_path).resolve()
|
|
274
|
+
if not project_dir.is_dir():
|
|
275
|
+
raise WorkspaceError(
|
|
276
|
+
"SUT2004",
|
|
277
|
+
f"workspace member path does not exist: {project_dir}",
|
|
278
|
+
source_path=atman_path,
|
|
279
|
+
details={"index": idx, "path": str(project_dir)},
|
|
280
|
+
)
|
|
281
|
+
project = _load_project(
|
|
282
|
+
project_dir,
|
|
283
|
+
workspace_default_substrate=default_substrate,
|
|
284
|
+
workspace_compiler_args=compiler_args,
|
|
285
|
+
workspace_member_overrides=entry,
|
|
286
|
+
workspace_atman_path=atman_path,
|
|
287
|
+
)
|
|
288
|
+
projects_unordered.append(project)
|
|
289
|
+
|
|
290
|
+
# Second pass: verify every dependency resolves to a project that
|
|
291
|
+
# actually exists in this workspace, and that the dependency's name
|
|
292
|
+
# matches the target project's declared name.
|
|
293
|
+
projects_by_name: dict[str, Project] = {}
|
|
294
|
+
for p in projects_unordered:
|
|
295
|
+
if p.name in projects_by_name:
|
|
296
|
+
raise WorkspaceError(
|
|
297
|
+
"SUT2007",
|
|
298
|
+
f"two projects in the same workspace share the name `{p.name}`",
|
|
299
|
+
source_path=atman_path,
|
|
300
|
+
)
|
|
301
|
+
projects_by_name[p.name] = p
|
|
302
|
+
for p in projects_unordered:
|
|
303
|
+
for dep in p.dependencies:
|
|
304
|
+
target = _find_project_by_dir(projects_unordered, dep.path)
|
|
305
|
+
if target is None:
|
|
306
|
+
raise WorkspaceError(
|
|
307
|
+
"SUT2013",
|
|
308
|
+
f"project `{p.name}` depends on a project outside the "
|
|
309
|
+
f"current workspace: {dep.path}",
|
|
310
|
+
source_path=p.atman_file,
|
|
311
|
+
)
|
|
312
|
+
if target.name != dep.name:
|
|
313
|
+
raise WorkspaceError(
|
|
314
|
+
"SUT2008",
|
|
315
|
+
f"dependency key `{dep.name}` in project `{p.name}` "
|
|
316
|
+
f"does not match target project's declared name "
|
|
317
|
+
f"`{target.name}`",
|
|
318
|
+
source_path=p.atman_file,
|
|
319
|
+
)
|
|
320
|
+
if target.name == p.name:
|
|
321
|
+
raise WorkspaceError(
|
|
322
|
+
"SUT2012",
|
|
323
|
+
f"project `{p.name}` declares a self-dependency",
|
|
324
|
+
source_path=p.atman_file,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Third pass: topologically sort. Kahn's algorithm with an explicit
|
|
328
|
+
# cycle detector that reports the cycle in order.
|
|
329
|
+
ordered = _topological_sort(projects_unordered, projects_by_name, atman_path)
|
|
330
|
+
|
|
331
|
+
return Workspace(
|
|
332
|
+
name=name,
|
|
333
|
+
sutra_version=sutra_version,
|
|
334
|
+
description=description,
|
|
335
|
+
default_substrate=default_substrate,
|
|
336
|
+
compiler_args=list(compiler_args),
|
|
337
|
+
atman_file=atman_path,
|
|
338
|
+
projects=ordered,
|
|
339
|
+
projects_by_name=projects_by_name,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ============================================================
|
|
344
|
+
# Project loading
|
|
345
|
+
# ============================================================
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _load_project(
|
|
349
|
+
project_dir: Path,
|
|
350
|
+
*,
|
|
351
|
+
workspace_default_substrate: str,
|
|
352
|
+
workspace_compiler_args: list[str],
|
|
353
|
+
workspace_member_overrides: dict[str, Any],
|
|
354
|
+
workspace_atman_path: Path,
|
|
355
|
+
) -> Project:
|
|
356
|
+
"""Locate, parse, validate, and return one member project.
|
|
357
|
+
|
|
358
|
+
The project file is always `atman.toml` at the member directory
|
|
359
|
+
root — there is no discovery heuristic, because the filename is
|
|
360
|
+
fixed by convention.
|
|
361
|
+
"""
|
|
362
|
+
atman_file = project_dir / "atman.toml"
|
|
363
|
+
if not atman_file.is_file():
|
|
364
|
+
raise WorkspaceError(
|
|
365
|
+
"SUT2005",
|
|
366
|
+
f"project directory has no atman.toml: {project_dir}",
|
|
367
|
+
source_path=workspace_atman_path,
|
|
368
|
+
)
|
|
369
|
+
doc = _read_toml(atman_file, is_workspace=False)
|
|
370
|
+
|
|
371
|
+
project_table = doc.get("project")
|
|
372
|
+
if not isinstance(project_table, dict):
|
|
373
|
+
raise WorkspaceError(
|
|
374
|
+
"SUT2007",
|
|
375
|
+
"project atman.toml is missing the [project] table",
|
|
376
|
+
source_path=atman_file,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
name = _require_string(
|
|
380
|
+
project_table, "name", code="SUT2007", source_path=atman_file,
|
|
381
|
+
)
|
|
382
|
+
if not PROJECT_NAME_RE.match(name):
|
|
383
|
+
raise WorkspaceError(
|
|
384
|
+
"SUT2007",
|
|
385
|
+
f"project name `{name}` is not a valid identifier "
|
|
386
|
+
f"(must match {PROJECT_NAME_RE.pattern})",
|
|
387
|
+
source_path=atman_file,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
entry_name = _require_string(
|
|
391
|
+
project_table, "entry", code="SUT2007", source_path=atman_file,
|
|
392
|
+
)
|
|
393
|
+
entry_path = (project_dir / entry_name).resolve()
|
|
394
|
+
if not entry_path.is_file():
|
|
395
|
+
raise WorkspaceError(
|
|
396
|
+
"SUT2009",
|
|
397
|
+
f"entry file does not exist: {entry_path}",
|
|
398
|
+
source_path=atman_file,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# Substrate resolution: workspace.member override > project
|
|
402
|
+
# atman.toml > workspace default.
|
|
403
|
+
substrate = workspace_member_overrides.get(
|
|
404
|
+
"substrate",
|
|
405
|
+
project_table.get("substrate", workspace_default_substrate),
|
|
406
|
+
)
|
|
407
|
+
if substrate not in VALID_SUBSTRATES:
|
|
408
|
+
raise WorkspaceError(
|
|
409
|
+
"SUT2014",
|
|
410
|
+
f"unknown substrate `{substrate}` for project `{name}`; "
|
|
411
|
+
f"must be one of {sorted(VALID_SUBSTRATES)}",
|
|
412
|
+
source_path=atman_file,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
description = project_table.get("description", "")
|
|
416
|
+
if not isinstance(description, str):
|
|
417
|
+
raise WorkspaceError(
|
|
418
|
+
"SUT2007",
|
|
419
|
+
"`project.description` must be a string",
|
|
420
|
+
source_path=atman_file,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
per_project_args = project_table.get("compiler_args", [])
|
|
424
|
+
if not isinstance(per_project_args, list) or not all(
|
|
425
|
+
isinstance(a, str) for a in per_project_args
|
|
426
|
+
):
|
|
427
|
+
raise WorkspaceError(
|
|
428
|
+
"SUT2007",
|
|
429
|
+
"`project.compiler_args` must be a list of strings",
|
|
430
|
+
source_path=atman_file,
|
|
431
|
+
)
|
|
432
|
+
combined_args = list(workspace_compiler_args) + list(per_project_args)
|
|
433
|
+
|
|
434
|
+
# Source file expansion.
|
|
435
|
+
sources_table = project_table.get("sources", {})
|
|
436
|
+
if not isinstance(sources_table, dict):
|
|
437
|
+
raise WorkspaceError(
|
|
438
|
+
"SUT2007",
|
|
439
|
+
"`project.sources` must be a table",
|
|
440
|
+
source_path=atman_file,
|
|
441
|
+
)
|
|
442
|
+
include_globs = sources_table.get("include", ["**/*.su"])
|
|
443
|
+
exclude_globs = sources_table.get("exclude", [])
|
|
444
|
+
for g in include_globs:
|
|
445
|
+
if not isinstance(g, str):
|
|
446
|
+
raise WorkspaceError(
|
|
447
|
+
"SUT2015",
|
|
448
|
+
"`project.sources.include` entries must be strings",
|
|
449
|
+
source_path=atman_file,
|
|
450
|
+
)
|
|
451
|
+
for g in exclude_globs:
|
|
452
|
+
if not isinstance(g, str):
|
|
453
|
+
raise WorkspaceError(
|
|
454
|
+
"SUT2015",
|
|
455
|
+
"`project.sources.exclude` entries must be strings",
|
|
456
|
+
source_path=atman_file,
|
|
457
|
+
)
|
|
458
|
+
sources = _expand_sources(project_dir, include_globs, exclude_globs)
|
|
459
|
+
|
|
460
|
+
# Dependencies.
|
|
461
|
+
deps_table = project_table.get("dependencies", {})
|
|
462
|
+
if not isinstance(deps_table, dict):
|
|
463
|
+
raise WorkspaceError(
|
|
464
|
+
"SUT2007",
|
|
465
|
+
"`project.dependencies` must be a table",
|
|
466
|
+
source_path=atman_file,
|
|
467
|
+
)
|
|
468
|
+
dependencies: list[ProjectDependency] = []
|
|
469
|
+
for dep_name, dep_ref in deps_table.items():
|
|
470
|
+
if not isinstance(dep_ref, dict):
|
|
471
|
+
raise WorkspaceError(
|
|
472
|
+
"SUT2007",
|
|
473
|
+
f"dependency `{dep_name}` must be a table "
|
|
474
|
+
f"(e.g. `{dep_name} = {{ path = \"../corpus\" }}`)",
|
|
475
|
+
source_path=atman_file,
|
|
476
|
+
)
|
|
477
|
+
dep_path_str = dep_ref.get("path")
|
|
478
|
+
if not isinstance(dep_path_str, str):
|
|
479
|
+
raise WorkspaceError(
|
|
480
|
+
"SUT2007",
|
|
481
|
+
f"dependency `{dep_name}` must have a `path` field",
|
|
482
|
+
source_path=atman_file,
|
|
483
|
+
)
|
|
484
|
+
dep_path = (project_dir / dep_path_str).resolve()
|
|
485
|
+
if not dep_path.is_dir():
|
|
486
|
+
raise WorkspaceError(
|
|
487
|
+
"SUT2010",
|
|
488
|
+
f"dependency `{dep_name}` of project `{name}` "
|
|
489
|
+
f"points to a directory that does not exist: {dep_path}",
|
|
490
|
+
source_path=atman_file,
|
|
491
|
+
)
|
|
492
|
+
dependencies.append(ProjectDependency(name=dep_name, path=dep_path))
|
|
493
|
+
|
|
494
|
+
return Project(
|
|
495
|
+
name=name,
|
|
496
|
+
path=project_dir,
|
|
497
|
+
atman_file=atman_file,
|
|
498
|
+
entry=entry_path,
|
|
499
|
+
substrate=substrate,
|
|
500
|
+
description=description,
|
|
501
|
+
compiler_args=combined_args,
|
|
502
|
+
sources=sources,
|
|
503
|
+
dependencies=dependencies,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _expand_sources(
|
|
508
|
+
project_dir: Path,
|
|
509
|
+
include_globs: list[str],
|
|
510
|
+
exclude_globs: list[str],
|
|
511
|
+
) -> list[Path]:
|
|
512
|
+
"""Expand include and exclude globs relative to `project_dir`.
|
|
513
|
+
|
|
514
|
+
Returns absolute paths, deduplicated and sorted for determinism.
|
|
515
|
+
The workspace's own `atman.toml` and any member atman.toml files
|
|
516
|
+
are excluded automatically so a `**/*.su` default doesn't trip
|
|
517
|
+
over them — they are not Sutra source.
|
|
518
|
+
"""
|
|
519
|
+
included: set[Path] = set()
|
|
520
|
+
for pattern in include_globs:
|
|
521
|
+
for match in project_dir.glob(pattern):
|
|
522
|
+
if match.is_file():
|
|
523
|
+
included.add(match.resolve())
|
|
524
|
+
excluded: set[Path] = set()
|
|
525
|
+
for pattern in exclude_globs:
|
|
526
|
+
for match in project_dir.glob(pattern):
|
|
527
|
+
if match.is_file():
|
|
528
|
+
excluded.add(match.resolve())
|
|
529
|
+
remaining = included - excluded
|
|
530
|
+
return sorted(remaining)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _find_project_by_dir(
|
|
534
|
+
projects: list[Project],
|
|
535
|
+
target_dir: Path,
|
|
536
|
+
) -> Project | None:
|
|
537
|
+
target = target_dir.resolve()
|
|
538
|
+
for p in projects:
|
|
539
|
+
if p.path.resolve() == target:
|
|
540
|
+
return p
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# ============================================================
|
|
545
|
+
# Topological sort + cycle detection
|
|
546
|
+
# ============================================================
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _topological_sort(
|
|
550
|
+
projects: list[Project],
|
|
551
|
+
projects_by_name: dict[str, Project],
|
|
552
|
+
atman_path: Path,
|
|
553
|
+
) -> list[Project]:
|
|
554
|
+
"""Kahn's algorithm with cycle reporting."""
|
|
555
|
+
in_degree: dict[str, int] = {p.name: 0 for p in projects}
|
|
556
|
+
adjacency: dict[str, list[str]] = {p.name: [] for p in projects}
|
|
557
|
+
for p in projects:
|
|
558
|
+
for dep in p.dependencies:
|
|
559
|
+
target = _find_project_by_dir(projects, dep.path)
|
|
560
|
+
assert target is not None # validated in load_workspace
|
|
561
|
+
adjacency[target.name].append(p.name)
|
|
562
|
+
in_degree[p.name] += 1
|
|
563
|
+
|
|
564
|
+
queue = [name for name, deg in in_degree.items() if deg == 0]
|
|
565
|
+
ordered: list[Project] = []
|
|
566
|
+
while queue:
|
|
567
|
+
name = queue.pop(0)
|
|
568
|
+
ordered.append(projects_by_name[name])
|
|
569
|
+
for consumer in adjacency[name]:
|
|
570
|
+
in_degree[consumer] -= 1
|
|
571
|
+
if in_degree[consumer] == 0:
|
|
572
|
+
queue.append(consumer)
|
|
573
|
+
|
|
574
|
+
if len(ordered) != len(projects):
|
|
575
|
+
remaining = [p.name for p in projects if in_degree[p.name] > 0]
|
|
576
|
+
raise WorkspaceError(
|
|
577
|
+
"SUT2011",
|
|
578
|
+
f"dependency cycle detected among projects: {remaining}",
|
|
579
|
+
source_path=atman_path,
|
|
580
|
+
details={"cycle": remaining},
|
|
581
|
+
)
|
|
582
|
+
return ordered
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
# ============================================================
|
|
586
|
+
# CLI convenience
|
|
587
|
+
# ============================================================
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _main(argv: Iterable[str] | None = None) -> int:
|
|
591
|
+
"""CLI for ad-hoc workspace validation.
|
|
592
|
+
|
|
593
|
+
Usage: `python -m sutra_compiler.workspace <path-to-atman.toml>`
|
|
594
|
+
"""
|
|
595
|
+
import argparse
|
|
596
|
+
import json
|
|
597
|
+
import sys
|
|
598
|
+
|
|
599
|
+
parser = argparse.ArgumentParser(
|
|
600
|
+
prog="sutra_compiler.workspace",
|
|
601
|
+
description="Parse and validate a Sutra workspace atman.toml.",
|
|
602
|
+
)
|
|
603
|
+
parser.add_argument("atman", help="Path to the workspace atman.toml")
|
|
604
|
+
parser.add_argument(
|
|
605
|
+
"--json", action="store_true",
|
|
606
|
+
help="Emit a JSON summary of the resolved workspace to stdout.",
|
|
607
|
+
)
|
|
608
|
+
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
609
|
+
|
|
610
|
+
try:
|
|
611
|
+
workspace = load_workspace(Path(args.atman))
|
|
612
|
+
except WorkspaceError as e:
|
|
613
|
+
print(str(e), file=sys.stderr)
|
|
614
|
+
return 1
|
|
615
|
+
|
|
616
|
+
if args.json:
|
|
617
|
+
out = {
|
|
618
|
+
"name": workspace.name,
|
|
619
|
+
"sutra_version": workspace.sutra_version,
|
|
620
|
+
"description": workspace.description,
|
|
621
|
+
"default_substrate": workspace.default_substrate,
|
|
622
|
+
"compiler_args": workspace.compiler_args,
|
|
623
|
+
"projects": [
|
|
624
|
+
{
|
|
625
|
+
"name": p.name,
|
|
626
|
+
"path": str(p.path),
|
|
627
|
+
"entry": str(p.entry),
|
|
628
|
+
"substrate": p.substrate,
|
|
629
|
+
"description": p.description,
|
|
630
|
+
"compiler_args": p.compiler_args,
|
|
631
|
+
"sources": [str(s) for s in p.sources],
|
|
632
|
+
"dependencies": [
|
|
633
|
+
{"name": d.name, "path": str(d.path)}
|
|
634
|
+
for d in p.dependencies
|
|
635
|
+
],
|
|
636
|
+
}
|
|
637
|
+
for p in workspace.projects
|
|
638
|
+
],
|
|
639
|
+
}
|
|
640
|
+
print(json.dumps(out, indent=2))
|
|
641
|
+
else:
|
|
642
|
+
print(f"workspace: {workspace.name} (v{workspace.sutra_version})")
|
|
643
|
+
print(f" default_substrate: {workspace.default_substrate}")
|
|
644
|
+
print(f" projects in build order:")
|
|
645
|
+
for p in workspace.projects:
|
|
646
|
+
deps = ", ".join(d.name for d in p.dependencies) or "(none)"
|
|
647
|
+
print(
|
|
648
|
+
f" - {p.name} [{p.substrate}] "
|
|
649
|
+
f"({len(p.sources)} source files, deps: {deps})"
|
|
650
|
+
)
|
|
651
|
+
return 0
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
if __name__ == "__main__":
|
|
655
|
+
raise SystemExit(_main())
|