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.
- namel3ss/__init__.py +4 -0
- namel3ss/ast/__init__.py +5 -0
- namel3ss/ast/agents.py +13 -0
- namel3ss/ast/ai.py +23 -0
- namel3ss/ast/base.py +10 -0
- namel3ss/ast/expressions.py +55 -0
- namel3ss/ast/nodes.py +86 -0
- namel3ss/ast/pages.py +43 -0
- namel3ss/ast/program.py +22 -0
- namel3ss/ast/records.py +27 -0
- namel3ss/ast/statements.py +107 -0
- namel3ss/ast/tool.py +11 -0
- namel3ss/cli/__init__.py +2 -0
- namel3ss/cli/actions_mode.py +39 -0
- namel3ss/cli/app_loader.py +22 -0
- namel3ss/cli/commands/action.py +27 -0
- namel3ss/cli/commands/run.py +43 -0
- namel3ss/cli/commands/ui.py +26 -0
- namel3ss/cli/commands/validate.py +23 -0
- namel3ss/cli/format_mode.py +30 -0
- namel3ss/cli/io/json_io.py +19 -0
- namel3ss/cli/io/read_source.py +16 -0
- namel3ss/cli/json_io.py +21 -0
- namel3ss/cli/lint_mode.py +29 -0
- namel3ss/cli/main.py +135 -0
- namel3ss/cli/new_mode.py +146 -0
- namel3ss/cli/runner.py +28 -0
- namel3ss/cli/studio_mode.py +22 -0
- namel3ss/cli/ui_mode.py +14 -0
- namel3ss/config/__init__.py +4 -0
- namel3ss/config/dotenv.py +33 -0
- namel3ss/config/loader.py +83 -0
- namel3ss/config/model.py +49 -0
- namel3ss/errors/__init__.py +2 -0
- namel3ss/errors/base.py +34 -0
- namel3ss/errors/render.py +22 -0
- namel3ss/format/__init__.py +3 -0
- namel3ss/format/formatter.py +18 -0
- namel3ss/format/rules.py +97 -0
- namel3ss/ir/__init__.py +3 -0
- namel3ss/ir/lowering/__init__.py +4 -0
- namel3ss/ir/lowering/agents.py +42 -0
- namel3ss/ir/lowering/ai.py +45 -0
- namel3ss/ir/lowering/expressions.py +49 -0
- namel3ss/ir/lowering/flow.py +21 -0
- namel3ss/ir/lowering/pages.py +48 -0
- namel3ss/ir/lowering/program.py +34 -0
- namel3ss/ir/lowering/records.py +25 -0
- namel3ss/ir/lowering/statements.py +122 -0
- namel3ss/ir/lowering/tools.py +16 -0
- namel3ss/ir/model/__init__.py +50 -0
- namel3ss/ir/model/agents.py +33 -0
- namel3ss/ir/model/ai.py +31 -0
- namel3ss/ir/model/base.py +20 -0
- namel3ss/ir/model/expressions.py +50 -0
- namel3ss/ir/model/pages.py +43 -0
- namel3ss/ir/model/program.py +28 -0
- namel3ss/ir/model/statements.py +76 -0
- namel3ss/ir/model/tools.py +11 -0
- namel3ss/ir/nodes.py +88 -0
- namel3ss/lexer/__init__.py +2 -0
- namel3ss/lexer/lexer.py +152 -0
- namel3ss/lexer/tokens.py +98 -0
- namel3ss/lint/__init__.py +4 -0
- namel3ss/lint/engine.py +125 -0
- namel3ss/lint/semantic.py +45 -0
- namel3ss/lint/text_scan.py +70 -0
- namel3ss/lint/types.py +22 -0
- namel3ss/parser/__init__.py +3 -0
- namel3ss/parser/agent.py +78 -0
- namel3ss/parser/ai.py +113 -0
- namel3ss/parser/constraints.py +37 -0
- namel3ss/parser/core.py +166 -0
- namel3ss/parser/expressions.py +105 -0
- namel3ss/parser/flow.py +37 -0
- namel3ss/parser/pages.py +76 -0
- namel3ss/parser/program.py +45 -0
- namel3ss/parser/records.py +66 -0
- namel3ss/parser/statements/__init__.py +27 -0
- namel3ss/parser/statements/control_flow.py +116 -0
- namel3ss/parser/statements/core.py +66 -0
- namel3ss/parser/statements/data.py +17 -0
- namel3ss/parser/statements/letset.py +22 -0
- namel3ss/parser/statements.py +1 -0
- namel3ss/parser/tokens.py +35 -0
- namel3ss/parser/tool.py +29 -0
- namel3ss/runtime/__init__.py +3 -0
- namel3ss/runtime/ai/http/client.py +24 -0
- namel3ss/runtime/ai/mock_provider.py +5 -0
- namel3ss/runtime/ai/provider.py +29 -0
- namel3ss/runtime/ai/providers/__init__.py +18 -0
- namel3ss/runtime/ai/providers/_shared/errors.py +20 -0
- namel3ss/runtime/ai/providers/_shared/parse.py +18 -0
- namel3ss/runtime/ai/providers/anthropic.py +55 -0
- namel3ss/runtime/ai/providers/gemini.py +50 -0
- namel3ss/runtime/ai/providers/mistral.py +51 -0
- namel3ss/runtime/ai/providers/mock.py +23 -0
- namel3ss/runtime/ai/providers/ollama.py +39 -0
- namel3ss/runtime/ai/providers/openai.py +55 -0
- namel3ss/runtime/ai/providers/registry.py +38 -0
- namel3ss/runtime/ai/trace.py +18 -0
- namel3ss/runtime/executor/__init__.py +3 -0
- namel3ss/runtime/executor/agents.py +91 -0
- namel3ss/runtime/executor/ai_runner.py +90 -0
- namel3ss/runtime/executor/api.py +54 -0
- namel3ss/runtime/executor/assign.py +40 -0
- namel3ss/runtime/executor/context.py +31 -0
- namel3ss/runtime/executor/executor.py +77 -0
- namel3ss/runtime/executor/expr_eval.py +110 -0
- namel3ss/runtime/executor/records_ops.py +64 -0
- namel3ss/runtime/executor/result.py +13 -0
- namel3ss/runtime/executor/signals.py +6 -0
- namel3ss/runtime/executor/statements.py +99 -0
- namel3ss/runtime/memory/manager.py +52 -0
- namel3ss/runtime/memory/profile.py +17 -0
- namel3ss/runtime/memory/semantic.py +20 -0
- namel3ss/runtime/memory/short_term.py +18 -0
- namel3ss/runtime/records/service.py +105 -0
- namel3ss/runtime/store/__init__.py +2 -0
- namel3ss/runtime/store/memory_store.py +62 -0
- namel3ss/runtime/tools/registry.py +13 -0
- namel3ss/runtime/ui/__init__.py +2 -0
- namel3ss/runtime/ui/actions.py +124 -0
- namel3ss/runtime/validators/__init__.py +2 -0
- namel3ss/runtime/validators/constraints.py +126 -0
- namel3ss/schema/__init__.py +2 -0
- namel3ss/schema/records.py +52 -0
- namel3ss/studio/__init__.py +4 -0
- namel3ss/studio/api.py +115 -0
- namel3ss/studio/edit/__init__.py +3 -0
- namel3ss/studio/edit/ops.py +80 -0
- namel3ss/studio/edit/selectors.py +74 -0
- namel3ss/studio/edit/transform.py +39 -0
- namel3ss/studio/server.py +175 -0
- namel3ss/studio/session.py +11 -0
- namel3ss/studio/web/app.js +248 -0
- namel3ss/studio/web/index.html +44 -0
- namel3ss/studio/web/styles.css +42 -0
- namel3ss/templates/__init__.py +3 -0
- namel3ss/templates/__pycache__/__init__.cpython-312.pyc +0 -0
- namel3ss/templates/ai_assistant/.gitignore +1 -0
- namel3ss/templates/ai_assistant/README.md +10 -0
- namel3ss/templates/ai_assistant/app.ai +30 -0
- namel3ss/templates/crud/.gitignore +1 -0
- namel3ss/templates/crud/README.md +10 -0
- namel3ss/templates/crud/app.ai +26 -0
- namel3ss/templates/multi_agent/.gitignore +1 -0
- namel3ss/templates/multi_agent/README.md +10 -0
- namel3ss/templates/multi_agent/app.ai +43 -0
- namel3ss/ui/__init__.py +2 -0
- namel3ss/ui/manifest.py +220 -0
- namel3ss/utils/__init__.py +2 -0
- namel3ss-0.1.0a0.dist-info/METADATA +123 -0
- namel3ss-0.1.0a0.dist-info/RECORD +157 -0
- namel3ss-0.1.0a0.dist-info/WHEEL +5 -0
- namel3ss-0.1.0a0.dist-info/entry_points.txt +2 -0
- 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,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,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
|
+
|
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,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
|