namel3ss 0.1.0a0__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.
Files changed (157) hide show
  1. namel3ss/__init__.py +4 -0
  2. namel3ss/ast/__init__.py +5 -0
  3. namel3ss/ast/agents.py +13 -0
  4. namel3ss/ast/ai.py +23 -0
  5. namel3ss/ast/base.py +10 -0
  6. namel3ss/ast/expressions.py +55 -0
  7. namel3ss/ast/nodes.py +86 -0
  8. namel3ss/ast/pages.py +43 -0
  9. namel3ss/ast/program.py +22 -0
  10. namel3ss/ast/records.py +27 -0
  11. namel3ss/ast/statements.py +107 -0
  12. namel3ss/ast/tool.py +11 -0
  13. namel3ss/cli/__init__.py +2 -0
  14. namel3ss/cli/actions_mode.py +39 -0
  15. namel3ss/cli/app_loader.py +22 -0
  16. namel3ss/cli/commands/action.py +27 -0
  17. namel3ss/cli/commands/run.py +43 -0
  18. namel3ss/cli/commands/ui.py +26 -0
  19. namel3ss/cli/commands/validate.py +23 -0
  20. namel3ss/cli/format_mode.py +30 -0
  21. namel3ss/cli/io/json_io.py +19 -0
  22. namel3ss/cli/io/read_source.py +16 -0
  23. namel3ss/cli/json_io.py +21 -0
  24. namel3ss/cli/lint_mode.py +29 -0
  25. namel3ss/cli/main.py +135 -0
  26. namel3ss/cli/new_mode.py +146 -0
  27. namel3ss/cli/runner.py +28 -0
  28. namel3ss/cli/studio_mode.py +22 -0
  29. namel3ss/cli/ui_mode.py +14 -0
  30. namel3ss/config/__init__.py +4 -0
  31. namel3ss/config/dotenv.py +33 -0
  32. namel3ss/config/loader.py +83 -0
  33. namel3ss/config/model.py +49 -0
  34. namel3ss/errors/__init__.py +2 -0
  35. namel3ss/errors/base.py +34 -0
  36. namel3ss/errors/render.py +22 -0
  37. namel3ss/format/__init__.py +3 -0
  38. namel3ss/format/formatter.py +18 -0
  39. namel3ss/format/rules.py +97 -0
  40. namel3ss/ir/__init__.py +3 -0
  41. namel3ss/ir/lowering/__init__.py +4 -0
  42. namel3ss/ir/lowering/agents.py +42 -0
  43. namel3ss/ir/lowering/ai.py +45 -0
  44. namel3ss/ir/lowering/expressions.py +49 -0
  45. namel3ss/ir/lowering/flow.py +21 -0
  46. namel3ss/ir/lowering/pages.py +48 -0
  47. namel3ss/ir/lowering/program.py +34 -0
  48. namel3ss/ir/lowering/records.py +25 -0
  49. namel3ss/ir/lowering/statements.py +122 -0
  50. namel3ss/ir/lowering/tools.py +16 -0
  51. namel3ss/ir/model/__init__.py +50 -0
  52. namel3ss/ir/model/agents.py +33 -0
  53. namel3ss/ir/model/ai.py +31 -0
  54. namel3ss/ir/model/base.py +20 -0
  55. namel3ss/ir/model/expressions.py +50 -0
  56. namel3ss/ir/model/pages.py +43 -0
  57. namel3ss/ir/model/program.py +28 -0
  58. namel3ss/ir/model/statements.py +76 -0
  59. namel3ss/ir/model/tools.py +11 -0
  60. namel3ss/ir/nodes.py +88 -0
  61. namel3ss/lexer/__init__.py +2 -0
  62. namel3ss/lexer/lexer.py +152 -0
  63. namel3ss/lexer/tokens.py +98 -0
  64. namel3ss/lint/__init__.py +4 -0
  65. namel3ss/lint/engine.py +125 -0
  66. namel3ss/lint/semantic.py +45 -0
  67. namel3ss/lint/text_scan.py +70 -0
  68. namel3ss/lint/types.py +22 -0
  69. namel3ss/parser/__init__.py +3 -0
  70. namel3ss/parser/agent.py +78 -0
  71. namel3ss/parser/ai.py +113 -0
  72. namel3ss/parser/constraints.py +37 -0
  73. namel3ss/parser/core.py +166 -0
  74. namel3ss/parser/expressions.py +105 -0
  75. namel3ss/parser/flow.py +37 -0
  76. namel3ss/parser/pages.py +76 -0
  77. namel3ss/parser/program.py +45 -0
  78. namel3ss/parser/records.py +66 -0
  79. namel3ss/parser/statements/__init__.py +27 -0
  80. namel3ss/parser/statements/control_flow.py +116 -0
  81. namel3ss/parser/statements/core.py +66 -0
  82. namel3ss/parser/statements/data.py +17 -0
  83. namel3ss/parser/statements/letset.py +22 -0
  84. namel3ss/parser/statements.py +1 -0
  85. namel3ss/parser/tokens.py +35 -0
  86. namel3ss/parser/tool.py +29 -0
  87. namel3ss/runtime/__init__.py +3 -0
  88. namel3ss/runtime/ai/http/client.py +24 -0
  89. namel3ss/runtime/ai/mock_provider.py +5 -0
  90. namel3ss/runtime/ai/provider.py +29 -0
  91. namel3ss/runtime/ai/providers/__init__.py +18 -0
  92. namel3ss/runtime/ai/providers/_shared/errors.py +20 -0
  93. namel3ss/runtime/ai/providers/_shared/parse.py +18 -0
  94. namel3ss/runtime/ai/providers/anthropic.py +55 -0
  95. namel3ss/runtime/ai/providers/gemini.py +50 -0
  96. namel3ss/runtime/ai/providers/mistral.py +51 -0
  97. namel3ss/runtime/ai/providers/mock.py +23 -0
  98. namel3ss/runtime/ai/providers/ollama.py +39 -0
  99. namel3ss/runtime/ai/providers/openai.py +55 -0
  100. namel3ss/runtime/ai/providers/registry.py +38 -0
  101. namel3ss/runtime/ai/trace.py +18 -0
  102. namel3ss/runtime/executor/__init__.py +3 -0
  103. namel3ss/runtime/executor/agents.py +91 -0
  104. namel3ss/runtime/executor/ai_runner.py +90 -0
  105. namel3ss/runtime/executor/api.py +54 -0
  106. namel3ss/runtime/executor/assign.py +40 -0
  107. namel3ss/runtime/executor/context.py +31 -0
  108. namel3ss/runtime/executor/executor.py +77 -0
  109. namel3ss/runtime/executor/expr_eval.py +110 -0
  110. namel3ss/runtime/executor/records_ops.py +64 -0
  111. namel3ss/runtime/executor/result.py +13 -0
  112. namel3ss/runtime/executor/signals.py +6 -0
  113. namel3ss/runtime/executor/statements.py +99 -0
  114. namel3ss/runtime/memory/manager.py +52 -0
  115. namel3ss/runtime/memory/profile.py +17 -0
  116. namel3ss/runtime/memory/semantic.py +20 -0
  117. namel3ss/runtime/memory/short_term.py +18 -0
  118. namel3ss/runtime/records/service.py +105 -0
  119. namel3ss/runtime/store/__init__.py +2 -0
  120. namel3ss/runtime/store/memory_store.py +62 -0
  121. namel3ss/runtime/tools/registry.py +13 -0
  122. namel3ss/runtime/ui/__init__.py +2 -0
  123. namel3ss/runtime/ui/actions.py +124 -0
  124. namel3ss/runtime/validators/__init__.py +2 -0
  125. namel3ss/runtime/validators/constraints.py +126 -0
  126. namel3ss/schema/__init__.py +2 -0
  127. namel3ss/schema/records.py +52 -0
  128. namel3ss/studio/__init__.py +4 -0
  129. namel3ss/studio/api.py +115 -0
  130. namel3ss/studio/edit/__init__.py +3 -0
  131. namel3ss/studio/edit/ops.py +80 -0
  132. namel3ss/studio/edit/selectors.py +74 -0
  133. namel3ss/studio/edit/transform.py +39 -0
  134. namel3ss/studio/server.py +175 -0
  135. namel3ss/studio/session.py +11 -0
  136. namel3ss/studio/web/app.js +248 -0
  137. namel3ss/studio/web/index.html +44 -0
  138. namel3ss/studio/web/styles.css +42 -0
  139. namel3ss/templates/__init__.py +3 -0
  140. namel3ss/templates/__pycache__/__init__.cpython-312.pyc +0 -0
  141. namel3ss/templates/ai_assistant/.gitignore +1 -0
  142. namel3ss/templates/ai_assistant/README.md +10 -0
  143. namel3ss/templates/ai_assistant/app.ai +30 -0
  144. namel3ss/templates/crud/.gitignore +1 -0
  145. namel3ss/templates/crud/README.md +10 -0
  146. namel3ss/templates/crud/app.ai +26 -0
  147. namel3ss/templates/multi_agent/.gitignore +1 -0
  148. namel3ss/templates/multi_agent/README.md +10 -0
  149. namel3ss/templates/multi_agent/app.ai +43 -0
  150. namel3ss/ui/__init__.py +2 -0
  151. namel3ss/ui/manifest.py +220 -0
  152. namel3ss/utils/__init__.py +2 -0
  153. namel3ss-0.1.0a0.dist-info/METADATA +123 -0
  154. namel3ss-0.1.0a0.dist-info/RECORD +157 -0
  155. namel3ss-0.1.0a0.dist-info/WHEEL +5 -0
  156. namel3ss-0.1.0a0.dist-info/entry_points.txt +2 -0
  157. namel3ss-0.1.0a0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Dict, Optional
5
+
6
+ from namel3ss.errors.base import Namel3ssError
7
+ from namel3ss.ir import nodes as ir
8
+ from namel3ss.runtime.executor import execute_program_flow
9
+ from namel3ss.runtime.records.service import save_record_with_errors
10
+ from namel3ss.runtime.store.memory_store import MemoryStore
11
+ from namel3ss.ui.manifest import build_manifest
12
+
13
+
14
+ def handle_action(
15
+ program_ir: ir.Program,
16
+ *,
17
+ action_id: str,
18
+ payload: Optional[dict] = None,
19
+ state: Optional[dict] = None,
20
+ store: Optional[MemoryStore] = None,
21
+ ) -> dict:
22
+ """Execute a UI action against the program."""
23
+ if payload is not None and not isinstance(payload, dict):
24
+ raise Namel3ssError("Payload must be a dictionary")
25
+
26
+ store = store or MemoryStore()
27
+ working_state = {} if state is None else state
28
+ manifest = build_manifest(program_ir, state=working_state, store=store)
29
+ actions: Dict[str, dict] = manifest.get("actions", {})
30
+ if action_id not in actions:
31
+ raise Namel3ssError(f"Unknown action '{action_id}'")
32
+
33
+ action = actions[action_id]
34
+ action_type = action.get("type")
35
+ if action_type == "call_flow":
36
+ return _handle_call_flow(program_ir, action, payload or {}, working_state, store, manifest)
37
+ if action_type == "submit_form":
38
+ return _handle_submit_form(program_ir, action, payload or {}, working_state, store, manifest)
39
+ raise Namel3ssError(f"Unsupported action type '{action_type}'")
40
+
41
+
42
+ def _ensure_json_serializable(data: dict) -> None:
43
+ try:
44
+ json.dumps(data)
45
+ except Exception as exc: # pragma: no cover - guard rail
46
+ raise Namel3ssError(f"Response is not JSON-serializable: {exc}")
47
+
48
+
49
+ def _handle_call_flow(
50
+ program_ir: ir.Program,
51
+ action: dict,
52
+ payload: dict,
53
+ state: dict,
54
+ store: MemoryStore,
55
+ manifest: dict,
56
+ ) -> dict:
57
+ flow_name = action.get("flow")
58
+ if not isinstance(flow_name, str):
59
+ raise Namel3ssError("Invalid flow reference in action")
60
+ result = execute_program_flow(
61
+ program_ir,
62
+ flow_name,
63
+ state=state,
64
+ input=payload,
65
+ store=store,
66
+ )
67
+ traces = [_trace_to_dict(t) for t in result.traces]
68
+ response = {
69
+ "ok": True,
70
+ "state": result.state,
71
+ "result": result.last_value,
72
+ "ui": build_manifest(program_ir, state=result.state, store=store),
73
+ "traces": traces,
74
+ }
75
+ _ensure_json_serializable(response)
76
+ return response
77
+
78
+
79
+ def _handle_submit_form(
80
+ program_ir: ir.Program,
81
+ action: dict,
82
+ payload: dict,
83
+ state: dict,
84
+ store: MemoryStore,
85
+ manifest: dict,
86
+ ) -> dict:
87
+ if "values" not in payload or not isinstance(payload.get("values"), dict):
88
+ raise Namel3ssError("Submit form payload must include a 'values' dictionary")
89
+ record = action.get("record")
90
+ if not isinstance(record, str):
91
+ raise Namel3ssError("Invalid record reference in form action")
92
+ values = payload["values"]
93
+ state_key = record.lower()
94
+ state[state_key] = values
95
+ schemas = {schema.name: schema for schema in program_ir.records}
96
+ saved, errors = save_record_with_errors(record, values, schemas, state, store)
97
+ if errors:
98
+ response = {
99
+ "ok": False,
100
+ "state": state,
101
+ "errors": errors,
102
+ "ui": build_manifest(program_ir, state=state, store=store),
103
+ "traces": [],
104
+ }
105
+ _ensure_json_serializable(response)
106
+ return response
107
+
108
+ record_id = saved.get("id") if isinstance(saved, dict) else None
109
+ record_id = record_id or (saved.get("_id") if isinstance(saved, dict) else None)
110
+ response = {
111
+ "ok": True,
112
+ "state": state,
113
+ "result": {"record": record, "id": record_id},
114
+ "ui": build_manifest(program_ir, state=state, store=store),
115
+ "traces": [],
116
+ }
117
+ _ensure_json_serializable(response)
118
+ return response
119
+
120
+
121
+ def _trace_to_dict(trace) -> dict:
122
+ if hasattr(trace, "__dict__"):
123
+ return trace.__dict__
124
+ return dict(trace)
@@ -0,0 +1,2 @@
1
+ """Runtime constraint validators."""
2
+
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Callable, Dict, Optional
5
+
6
+ from namel3ss.errors.base import Namel3ssError
7
+ from namel3ss.schema.records import FieldConstraint, FieldSchema, RecordSchema
8
+
9
+
10
+ def validate_record_instance(
11
+ schema: RecordSchema,
12
+ data: Dict[str, object],
13
+ evaluate_expr: Callable[[object], object],
14
+ ) -> None:
15
+ for field in schema.fields:
16
+ error = _field_error(schema.name, field, data, evaluate_expr)
17
+ if error:
18
+ raise Namel3ssError(error["message"])
19
+
20
+
21
+ def _field_error(
22
+ record_name: str,
23
+ field: FieldSchema,
24
+ data: Dict[str, object],
25
+ evaluate_expr: Callable[[object], object],
26
+ ) -> Dict[str, str] | None:
27
+ value = data.get(field.name)
28
+ if field.constraint is None:
29
+ return None
30
+ constraint = field.constraint
31
+ if constraint.kind == "present":
32
+ if value is None:
33
+ return {"field": field.name, "code": "present", "message": f"Field '{field.name}' in record '{record_name}' must be present"}
34
+ return None
35
+ if constraint.kind == "unique":
36
+ return None
37
+ if constraint.kind in {"gt", "lt"}:
38
+ if not isinstance(value, (int, float)):
39
+ return {
40
+ "field": field.name,
41
+ "code": "type",
42
+ "message": f"Field '{field.name}' in record '{record_name}' must be numeric",
43
+ }
44
+ compare_value = evaluate_expr(constraint.expression)
45
+ if not isinstance(compare_value, (int, float)):
46
+ return {
47
+ "field": field.name,
48
+ "code": "type",
49
+ "message": f"Constraint for field '{field.name}' in record '{record_name}' must be numeric",
50
+ }
51
+ if constraint.kind == "gt" and not (value > compare_value):
52
+ return {
53
+ "field": field.name,
54
+ "code": "gt",
55
+ "message": f"Field '{field.name}' in record '{record_name}' must be greater than {compare_value}",
56
+ }
57
+ if constraint.kind == "lt" and not (value < compare_value):
58
+ return {
59
+ "field": field.name,
60
+ "code": "lt",
61
+ "message": f"Field '{field.name}' in record '{record_name}' must be less than {compare_value}",
62
+ }
63
+ return None
64
+ if constraint.kind in {"len_min", "len_max"}:
65
+ if value is None:
66
+ return {
67
+ "field": field.name,
68
+ "code": "present",
69
+ "message": f"Field '{field.name}' in record '{record_name}' must be present for length check",
70
+ }
71
+ try:
72
+ length = len(value) # type: ignore[arg-type]
73
+ except Exception:
74
+ return {
75
+ "field": field.name,
76
+ "code": "type",
77
+ "message": f"Field '{field.name}' in record '{record_name}' must support length checks",
78
+ }
79
+ compare_value = evaluate_expr(constraint.expression)
80
+ if not isinstance(compare_value, (int, float)):
81
+ return {
82
+ "field": field.name,
83
+ "code": "type",
84
+ "message": f"Constraint for field '{field.name}' in record '{record_name}' must be numeric",
85
+ }
86
+ if constraint.kind == "len_min" and length < compare_value:
87
+ return {
88
+ "field": field.name,
89
+ "code": "min_length",
90
+ "message": f"Field '{field.name}' in record '{record_name}' must have length at least {compare_value}",
91
+ }
92
+ if constraint.kind == "len_max" and length > compare_value:
93
+ return {
94
+ "field": field.name,
95
+ "code": "max_length",
96
+ "message": f"Field '{field.name}' in record '{record_name}' must have length at most {compare_value}",
97
+ }
98
+ return None
99
+ if constraint.kind == "pattern":
100
+ if not isinstance(value, str):
101
+ return {
102
+ "field": field.name,
103
+ "code": "type",
104
+ "message": f"Field '{field.name}' in record '{record_name}' must be a string to match pattern",
105
+ }
106
+ if not re.fullmatch(constraint.pattern or "", value):
107
+ return {
108
+ "field": field.name,
109
+ "code": "pattern",
110
+ "message": f"Field '{field.name}' in record '{record_name}' must match pattern {constraint.pattern}",
111
+ }
112
+ return None
113
+ return None
114
+
115
+
116
+ def collect_validation_errors(
117
+ schema: RecordSchema,
118
+ data: Dict[str, object],
119
+ evaluate_expr: Callable[[object], object],
120
+ ) -> List[Dict[str, str]]:
121
+ errors: List[Dict[str, str]] = []
122
+ for field in schema.fields:
123
+ err = _field_error(schema.name, field, data, evaluate_expr)
124
+ if err:
125
+ errors.append(err)
126
+ return errors
@@ -0,0 +1,2 @@
1
+ """Schema definitions for Namel3ss records."""
2
+
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Dict, List, Optional
5
+
6
+ from namel3ss.errors.base import Namel3ssError
7
+ from namel3ss.ir import nodes as ir
8
+
9
+ SUPPORTED_TYPES = {"string", "int", "number", "boolean", "json"}
10
+
11
+
12
+ @dataclass
13
+ class FieldConstraint:
14
+ kind: str # present, unique, gt, lt, pattern, len_min, len_max
15
+ expression: Optional[ir.Expression] = None
16
+ pattern: Optional[str] = None
17
+
18
+
19
+ @dataclass
20
+ class FieldSchema:
21
+ name: str
22
+ type_name: str
23
+ constraint: Optional[FieldConstraint] = None
24
+
25
+
26
+ @dataclass
27
+ class RecordSchema:
28
+ name: str
29
+ fields: List[FieldSchema] = field(default_factory=list)
30
+
31
+ def __post_init__(self) -> None:
32
+ self._validate_schema()
33
+ self.field_map: Dict[str, FieldSchema] = {f.name: f for f in self.fields}
34
+ self.unique_fields = {f.name for f in self.fields if f.constraint and f.constraint.kind == "unique"}
35
+
36
+ def _validate_schema(self) -> None:
37
+ seen: set[str] = set()
38
+ for f in self.fields:
39
+ if f.name in seen:
40
+ raise Namel3ssError(f"Duplicate field '{f.name}' in record '{self.name}'")
41
+ seen.add(f.name)
42
+ if f.type_name not in SUPPORTED_TYPES:
43
+ raise Namel3ssError(f"Unsupported field type '{f.type_name}' in record '{self.name}'")
44
+ if f.constraint and f.constraint.kind in {"gt", "lt", "len_min", "len_max"} and f.constraint.expression is None:
45
+ raise Namel3ssError(
46
+ f"Constraint '{f.constraint.kind}' requires an expression in record '{self.name}' field '{f.name}'"
47
+ )
48
+ if f.constraint and f.constraint.kind == "pattern" and not f.constraint.pattern:
49
+ raise Namel3ssError(
50
+ f"Constraint 'pattern' requires a regex string in record '{self.name}' field '{f.name}'"
51
+ )
52
+
@@ -0,0 +1,4 @@
1
+ from namel3ss.studio.api import get_actions_payload, get_lint_payload, get_summary_payload, get_ui_payload
2
+ from namel3ss.studio.server import start_server
3
+
4
+ __all__ = ["get_summary_payload", "get_ui_payload", "get_actions_payload", "get_lint_payload", "start_server"]
namel3ss/studio/api.py ADDED
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from namel3ss.errors.base import Namel3ssError
6
+ from namel3ss.errors.render import format_error
7
+ from namel3ss.ir.nodes import lower_program
8
+ from namel3ss.lint.engine import lint_source
9
+ from namel3ss.parser.core import parse
10
+ from namel3ss.runtime.store.memory_store import MemoryStore
11
+ from namel3ss.runtime.ui.actions import handle_action
12
+ from namel3ss.studio.edit import apply_edit_to_source
13
+ from namel3ss.studio.session import SessionState
14
+ from namel3ss.ui.manifest import build_manifest
15
+
16
+
17
+ def _load_program(source: str):
18
+ ast_program = parse(source)
19
+ return lower_program(ast_program)
20
+
21
+
22
+ def get_summary_payload(source: str, path: str) -> dict:
23
+ try:
24
+ program_ir = _load_program(source)
25
+ counts = {
26
+ "records": len(program_ir.records),
27
+ "flows": len(program_ir.flows),
28
+ "pages": len(program_ir.pages),
29
+ "ais": len(program_ir.ais),
30
+ "agents": len(program_ir.agents),
31
+ "tools": len(program_ir.tools),
32
+ }
33
+ return {"ok": True, "file": path, "counts": counts}
34
+ except Namel3ssError as err:
35
+ return {"ok": False, "error": format_error(err, source)}
36
+
37
+
38
+ def get_ui_payload(source: str, session: SessionState | None = None) -> dict:
39
+ try:
40
+ program_ir = _load_program(source)
41
+ manifest = build_manifest(
42
+ program_ir,
43
+ state=session.state if session else {},
44
+ store=session.store if session else MemoryStore(),
45
+ )
46
+ return manifest
47
+ except Namel3ssError as err:
48
+ return {"ok": False, "error": format_error(err, source)}
49
+
50
+
51
+ def get_actions_payload(source: str) -> dict:
52
+ try:
53
+ program_ir = _load_program(source)
54
+ manifest = build_manifest(program_ir, state={}, store=MemoryStore())
55
+ data = _actions_from_manifest(manifest)
56
+ return {"ok": True, "count": len(data), "actions": data}
57
+ except Namel3ssError as err:
58
+ return {"ok": False, "error": format_error(err, source)}
59
+
60
+
61
+ def get_lint_payload(source: str) -> dict:
62
+ findings = lint_source(source)
63
+ return {
64
+ "ok": len(findings) == 0,
65
+ "count": len(findings),
66
+ "findings": [f.to_dict() for f in findings],
67
+ }
68
+
69
+
70
+ def execute_action(source: str, session: SessionState, action_id: str, payload: dict) -> dict:
71
+ program_ir = _load_program(source)
72
+ return handle_action(program_ir, action_id=action_id, payload=payload, state=session.state, store=session.store)
73
+
74
+
75
+ def apply_edit(app_path: str, op: str, target: dict, value: str, session: SessionState) -> dict:
76
+ source_text = Path(app_path).read_text(encoding="utf-8")
77
+ formatted_source, program_ir, manifest = apply_edit_to_source(source_text, op, target, value, session)
78
+ Path(app_path).write_text(formatted_source, encoding="utf-8")
79
+ actions = _actions_from_manifest(manifest)
80
+ lint_payload = get_lint_payload(formatted_source)
81
+ summary = _summary_from_program(program_ir, app_path)
82
+ return {
83
+ "ok": True,
84
+ "ui": manifest,
85
+ "actions": {"ok": True, "count": len(actions), "actions": actions},
86
+ "lint": lint_payload,
87
+ "summary": summary,
88
+ }
89
+
90
+
91
+ def _actions_from_manifest(manifest: dict) -> list[dict]:
92
+ actions = manifest.get("actions", {})
93
+ sorted_ids = sorted(actions.keys())
94
+ data = []
95
+ for action_id in sorted_ids:
96
+ entry = actions[action_id]
97
+ item = {"id": action_id, "type": entry.get("type")}
98
+ if entry.get("type") == "call_flow":
99
+ item["flow"] = entry.get("flow")
100
+ if entry.get("type") == "submit_form":
101
+ item["record"] = entry.get("record")
102
+ data.append(item)
103
+ return data
104
+
105
+
106
+ def _summary_from_program(program_ir, path: str) -> dict:
107
+ counts = {
108
+ "records": len(program_ir.records),
109
+ "flows": len(program_ir.flows),
110
+ "pages": len(program_ir.pages),
111
+ "ais": len(program_ir.ais),
112
+ "agents": len(program_ir.agents),
113
+ "tools": len(program_ir.tools),
114
+ }
115
+ return {"ok": True, "file": path, "counts": counts}
@@ -0,0 +1,3 @@
1
+ from namel3ss.studio.edit.ops import apply_edit_to_source
2
+
3
+ __all__ = ["apply_edit_to_source"]
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from namel3ss.errors.base import Namel3ssError
4
+ from namel3ss.format.formatter import format_source
5
+ from namel3ss.ir import nodes as ir
6
+ from namel3ss.ir.nodes import lower_program
7
+ from namel3ss.parser.core import parse
8
+ from namel3ss.runtime.store.memory_store import MemoryStore
9
+ from namel3ss.studio.edit.selectors import find_element, find_line_number
10
+ from namel3ss.studio.edit.transform import replace_literal_at_line
11
+ from namel3ss.studio.session import SessionState
12
+ from namel3ss.ui.manifest import build_manifest
13
+
14
+ SUPPORTED_OPS = {"set_title", "set_text", "set_button_label"}
15
+
16
+
17
+ def apply_edit_to_source(
18
+ source: str,
19
+ op: str,
20
+ target: dict,
21
+ value: str,
22
+ session: SessionState | None = None,
23
+ ) -> tuple[str, ir.Program, dict]:
24
+ if op not in SUPPORTED_OPS:
25
+ raise Namel3ssError(f"Unsupported edit op '{op}'")
26
+ if not isinstance(target, dict):
27
+ raise Namel3ssError("Edit target must be an object")
28
+ page_name = target.get("page")
29
+ element_id = target.get("element_id")
30
+ if not isinstance(page_name, str) or not isinstance(element_id, str):
31
+ raise Namel3ssError("Edit target must include 'page' and 'element_id'")
32
+ if not isinstance(value, str):
33
+ raise Namel3ssError("Edit value must be a string")
34
+
35
+ program_ir = _lower(source)
36
+ manifest = build_manifest(program_ir, state=_session_state(session), store=_session_store(session))
37
+ element, page = find_element(manifest, element_id)
38
+ if page.get("name") != page_name:
39
+ raise Namel3ssError(f"Element '{element_id}' does not belong to page '{page_name}'")
40
+
41
+ old_text = _element_value_for_op(element, op)
42
+ line_no = find_line_number(source, page_name, element)
43
+ updated_source = replace_literal_at_line(source, line_no, old_text, value)
44
+ formatted = format_source(updated_source)
45
+ updated_ir = _lower(formatted)
46
+ updated_manifest = build_manifest(updated_ir, state=_session_state(session), store=_session_store(session))
47
+ return formatted, updated_ir, updated_manifest
48
+
49
+
50
+ def _lower(source: str):
51
+ ast_program = parse(source)
52
+ return lower_program(ast_program)
53
+
54
+
55
+ def _element_value_for_op(element: dict, op: str) -> str | None:
56
+ if op == "set_title":
57
+ if element.get("type") != "title":
58
+ raise Namel3ssError("Target is not a title item")
59
+ return element.get("value")
60
+ if op == "set_text":
61
+ if element.get("type") != "text":
62
+ raise Namel3ssError("Target is not a text item")
63
+ return element.get("value")
64
+ if op == "set_button_label":
65
+ if element.get("type") != "button":
66
+ raise Namel3ssError("Target is not a button item")
67
+ return element.get("label")
68
+ return None
69
+
70
+
71
+ def _session_state(session: SessionState | None) -> dict:
72
+ if session is None:
73
+ return {}
74
+ return session.state
75
+
76
+
77
+ def _session_store(session: SessionState | None) -> MemoryStore | None:
78
+ if session is None:
79
+ return None
80
+ return session.store
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Tuple
5
+
6
+ from namel3ss.errors.base import Namel3ssError
7
+
8
+
9
+ def find_element(manifest: dict, element_id: str) -> Tuple[dict, dict]:
10
+ """Locate an element by element_id in the manifest."""
11
+ for page in manifest.get("pages", []):
12
+ for element in page.get("elements", []):
13
+ if element.get("element_id") == element_id:
14
+ return element, page
15
+ raise Namel3ssError(f"Unknown element id '{element_id}'")
16
+
17
+
18
+ def find_line_number(source: str, page_name: str, element: dict) -> int:
19
+ """Best-effort line lookup for an element."""
20
+ line = element.get("line")
21
+ if isinstance(line, int) and line > 0:
22
+ return line
23
+ element_type = element.get("type")
24
+ index = element.get("index", 0)
25
+ line_from_scan = _scan_page_for_element(source, page_name, element_type, index)
26
+ if line_from_scan is None:
27
+ raise Namel3ssError(f"Could not locate element '{element.get('element_id')}' in source")
28
+ return line_from_scan
29
+
30
+
31
+ def _scan_page_for_element(source: str, page_name: str, element_type: str | None, index: int) -> int | None:
32
+ """Fallback scanner when precise line info is missing."""
33
+ lines = source.splitlines()
34
+ page_header = re.compile(rf"^\s*page\s+\"{re.escape(page_name)}\"\s*:")
35
+ in_page = False
36
+ page_indent = 0
37
+ count = 0
38
+ pattern = _element_pattern(element_type)
39
+ for lineno, line in enumerate(lines, start=1):
40
+ if not in_page:
41
+ if page_header.match(line):
42
+ in_page = True
43
+ page_indent = _leading_spaces(line)
44
+ continue
45
+ # inside page
46
+ if line.strip() == "":
47
+ continue
48
+ indent = _leading_spaces(line)
49
+ if indent <= page_indent:
50
+ # page block ended
51
+ break
52
+ if pattern and pattern.match(line):
53
+ if count == index:
54
+ return lineno
55
+ count += 1
56
+ return None
57
+
58
+
59
+ def _leading_spaces(line: str) -> int:
60
+ return len(line) - len(line.lstrip(" "))
61
+
62
+
63
+ def _element_pattern(element_type: str | None):
64
+ if element_type == "title":
65
+ return re.compile(r'^\s*title\s+is\s+".*"$')
66
+ if element_type == "text":
67
+ return re.compile(r'^\s*text\s+is\s+".*"$')
68
+ if element_type == "button":
69
+ return re.compile(r'^\s*button\s+".*"\s*:')
70
+ if element_type == "form":
71
+ return re.compile(r'^\s*form\s+is\s+".*"')
72
+ if element_type == "table":
73
+ return re.compile(r'^\s*table\s+is\s+".*"')
74
+ return None
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from namel3ss.errors.base import Namel3ssError
6
+
7
+
8
+ def replace_literal_at_line(source: str, line_no: int, expected: str | None, new_value: str) -> str:
9
+ lines = source.splitlines()
10
+ if line_no < 1 or line_no > len(lines):
11
+ raise Namel3ssError("Element line number out of bounds")
12
+ line_idx = line_no - 1
13
+ line = lines[line_idx]
14
+ matches = list(re.finditer(r'"([^"]*)"', line))
15
+ if not matches:
16
+ raise Namel3ssError("Unable to find a string literal to edit")
17
+ match = _select_match(matches, expected)
18
+ if match is None:
19
+ raise Namel3ssError("Expected string literal not found on target line")
20
+ escaped_value = _escape_string(new_value)
21
+ new_line = line[: match.start()] + f'"{escaped_value}"' + line[match.end() :]
22
+ lines[line_idx] = new_line
23
+ updated = "\n".join(lines)
24
+ if source.endswith("\n"):
25
+ updated += "\n"
26
+ return updated
27
+
28
+
29
+ def _escape_string(value: str) -> str:
30
+ return value.replace("\\", "\\\\").replace('"', '\\"')
31
+
32
+
33
+ def _select_match(matches: list[re.Match[str]], expected: str | None):
34
+ if expected is None:
35
+ return matches[0]
36
+ for m in matches:
37
+ if m.group(1) == expected:
38
+ return m
39
+ return None