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.
@@ -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())