sagex-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sagex/pusher.py ADDED
@@ -0,0 +1,350 @@
1
+ """Read local resource files and push them to the server (create/update).
2
+
3
+ The file-side of `sagex push`: locate a local workflow file (by path or by name
4
+ under the workspace), parse + validate it, and shape the payload the API expects.
5
+ The actual POST/PUT go through sagex.api.resources; the CLI layer owns the prompts
6
+ (confirm on update, stale-write guard). No terminal formatting or prompts here.
7
+
8
+ Create-vs-update is decided by the `id` field: a file carrying a server id is an
9
+ update to that workflow, one without is a brand-new create. (Workflow names are
10
+ not unique server-side, so the id is the only reliable key.)
11
+ """
12
+
13
+ import json
14
+ import re
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ from sagex.copier import safe_name
19
+
20
+ # Fields the workflow write endpoints accept; everything else (timestamps, id) is
21
+ # server-owned and read-only.
22
+ _PAYLOAD_FIELDS = ("name", "description", "nodes", "edges")
23
+
24
+ # Reverse of the server's LANGUAGE_MAP (scripts/serializers.py): file extension ->
25
+ # language keyword the create endpoint accepts. '.yml' is an alias the server lacks
26
+ # (it stores yaml as '.yaml'), so a foo.yml pushes as foo.yaml — see script_push_spec.
27
+ _EXT_TO_LANGUAGE = {
28
+ "js": "javascript", "ts": "typescript", "py": "python", "java": "java",
29
+ "cpp": "cpp", "c": "c", "cs": "csharp", "go": "go", "rs": "rust",
30
+ "rb": "ruby", "php": "php", "swift": "swift", "kt": "kotlin",
31
+ "ps1": "powershell", "sh": "shell", "sql": "sql", "html": "html",
32
+ "css": "css", "json": "json", "xml": "xml", "yaml": "yaml", "yml": "yaml",
33
+ "md": "markdown",
34
+ }
35
+
36
+ # language -> the extension the server will actually store the file under. Lets us
37
+ # predict the server-side filename so create-vs-update matching is reliable.
38
+ _LANGUAGE_EXT = {
39
+ "javascript": "js", "typescript": "ts", "python": "py", "java": "java",
40
+ "cpp": "cpp", "c": "c", "csharp": "cs", "go": "go", "rust": "rs",
41
+ "ruby": "rb", "php": "php", "swift": "swift", "kotlin": "kt",
42
+ "powershell": "ps1", "shell": "sh", "bash": "sh", "sql": "sql",
43
+ "html": "html", "css": "css", "json": "json", "xml": "xml",
44
+ "yaml": "yaml", "markdown": "md",
45
+ }
46
+
47
+ # The server's script-name rule (letters, numbers, _ and - only — no dots/spaces).
48
+ _SCRIPT_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
49
+
50
+
51
+ def is_valid_script_name(name: str) -> bool:
52
+ """True if `name` is a legal script name (letters/numbers/_/-, no extension/dot)."""
53
+ return bool(_SCRIPT_NAME_RE.match(name or ""))
54
+
55
+ # Comment syntax per language, for the `new script` scaffold header.
56
+ _LINE_COMMENT = {
57
+ "python": "#", "shell": "#", "bash": "#", "ruby": "#", "yaml": "#", "powershell": "#",
58
+ "javascript": "//", "typescript": "//", "java": "//", "cpp": "//", "c": "//",
59
+ "csharp": "//", "go": "//", "rust": "//", "php": "//", "swift": "//", "kotlin": "//",
60
+ "sql": "--",
61
+ }
62
+ _BLOCK_COMMENT = {
63
+ "css": ("/*", "*/"),
64
+ "html": ("<!--", "-->"),
65
+ "xml": ("<!--", "-->"),
66
+ "markdown": ("<!--", "-->"),
67
+ }
68
+
69
+ # The scaffold header body (language-agnostic; re-prefixed with each language's comment
70
+ # char). Documents how params/secrets reach a script and how to emit output — this
71
+ # mirrors the Autosage exec engine: params are injected as environment variables (and
72
+ # {{NAME}} placeholders are substituted in the body before the run).
73
+ _SCRIPT_DOC_LINES = [
74
+ "Script created by `sagex new script`.",
75
+ "Edit it, then push it with: sagex push script <this-file>",
76
+ "",
77
+ "PARAMETERS (defined on the workflow's script node)",
78
+ " Each parameter is delivered to this script as an environment variable named",
79
+ " exactly as it is declared:",
80
+ " bash / shell : $NAME or ${NAME}",
81
+ " powershell : $env:NAME",
82
+ " You can also write {{NAME}} inline in this file (double braces, case-insensitive);",
83
+ " Autosage substitutes the value before the script runs.",
84
+ " Parameter names must be letters/digits/underscore and not start with a digit",
85
+ " (e.g. SERVICE_NAME). Names that don't qualify are skipped.",
86
+ " Run on its own (sagex run script), the script asks only for the {{NAME}}",
87
+ " markers in this file, so write {{NAME}} for every input it needs.",
88
+ "",
89
+ 'SECRETS (parameters of type "password", or a value from a vault credential)',
90
+ " Delivered the same way, as an environment variable ($PASSWORD / $env:PASSWORD).",
91
+ " They are never placed on the command line, so they stay out of the process list.",
92
+ " Do NOT print or echo secrets: log masking is a safety net, not a guarantee.",
93
+ "",
94
+ "OUTPUT FOR LATER NODES",
95
+ ' Print a JSON object to stdout, e.g. {"status": "active", "exists": true}',
96
+ " Later nodes then read those fields as {{this-node-id.output.status}}.",
97
+ " To use an upstream node's output here, set a parameter's value to",
98
+ " {{other-node-id.output.field}} and read that parameter as $NAME.",
99
+ "",
100
+ "EXIT CODE",
101
+ " Exit 0 means success; a non-zero exit marks this node failed and can stop the run.",
102
+ "",
103
+ "Write your code below.",
104
+ ]
105
+
106
+
107
+ class WorkflowFileNotFound(Exception):
108
+ """No workflow file matched the given path-or-name."""
109
+
110
+ def __init__(self, ref: str, looked: list[Path]) -> None:
111
+ super().__init__(f"No workflow file for '{ref}'.")
112
+ self.ref = ref
113
+ self.looked = looked
114
+
115
+
116
+ def resolve_workflow_file(workspace: Path, ref: str) -> Path:
117
+ """Find a workflow file by explicit path, or by name under <ws>/workflows/.
118
+
119
+ Tries, in order: the ref as a path; then <ws>/workflows/{ref}, {ref}.json, and
120
+ the sanitized {safe}.json (so a display name like "Nightly Disk Cleanup" finds
121
+ the copied "Nightly_Disk_Cleanup.json").
122
+ """
123
+ direct = Path(ref)
124
+ if direct.is_file():
125
+ return direct
126
+
127
+ folder = workspace / "workflows"
128
+ stem = ref[:-5] if ref.lower().endswith(".json") else ref
129
+ candidates = [
130
+ folder / ref,
131
+ folder / f"{ref}.json",
132
+ folder / f"{safe_name(stem, stem)}.json",
133
+ ]
134
+ seen = []
135
+ for c in candidates:
136
+ if c not in seen:
137
+ seen.append(c)
138
+ if c.is_file():
139
+ return c
140
+ raise WorkflowFileNotFound(ref, [direct, *seen])
141
+
142
+
143
+ def load_doc(path: Path) -> dict:
144
+ """Parse a JSON workflow file. Raises ValueError with a clear message on bad JSON.
145
+
146
+ Reads as utf-8-sig so a UTF-8 BOM (which Windows editors / PowerShell often add)
147
+ is transparently stripped; it's a no-op for BOM-free files like the ones `copy` writes.
148
+ """
149
+ try:
150
+ return json.loads(path.read_text(encoding="utf-8-sig"))
151
+ except (json.JSONDecodeError, OSError) as exc:
152
+ raise ValueError(f"Couldn't read {path}: {exc}")
153
+
154
+
155
+ def validate_workflow_doc(doc: dict) -> list[str]:
156
+ """Return a list of problems (empty = valid). Mirrors the web Import dialog's checks."""
157
+ if not isinstance(doc, dict):
158
+ return ["file is not a JSON object"]
159
+
160
+ problems: list[str] = []
161
+ if not str(doc.get("name") or "").strip():
162
+ problems.append("missing 'name'")
163
+
164
+ nodes = doc.get("nodes")
165
+ if not isinstance(nodes, list):
166
+ problems.append("'nodes' must be a list")
167
+ else:
168
+ for i, n in enumerate(nodes):
169
+ if not isinstance(n, dict) or not all(k in n for k in ("id", "type", "position")):
170
+ problems.append(f"node[{i}] must have id, type, position")
171
+
172
+ edges = doc.get("edges")
173
+ if not isinstance(edges, list):
174
+ problems.append("'edges' must be a list")
175
+ else:
176
+ for i, e in enumerate(edges):
177
+ if not isinstance(e, dict) or not all(k in e for k in ("id", "source", "target")):
178
+ problems.append(f"edge[{i}] must have id, source, target")
179
+
180
+ return problems
181
+
182
+
183
+ def workflow_payload(doc: dict, name_override: str | None = None) -> dict:
184
+ """Build the create/update payload: only the server-writable fields."""
185
+ payload = {k: doc.get(k) for k in _PAYLOAD_FIELDS if k in doc}
186
+ payload.setdefault("description", "")
187
+ payload.setdefault("nodes", [])
188
+ payload.setdefault("edges", [])
189
+ if name_override:
190
+ payload["name"] = name_override
191
+ return payload
192
+
193
+
194
+ def server_is_newer(server_modified, local_modified) -> bool:
195
+ """True if the server's modified_at is strictly newer than the local file's.
196
+
197
+ Used for the stale-write guard: warns before an update would clobber changes
198
+ made (e.g. in the web UI) since the file was copied. False if either is unparseable.
199
+ """
200
+ try:
201
+ return datetime.fromisoformat(server_modified) > datetime.fromisoformat(local_modified)
202
+ except (ValueError, TypeError):
203
+ return False
204
+
205
+
206
+ def sync_meta_back(path: Path, doc: dict, server_record: dict) -> None:
207
+ """Write the server's id/timestamps back into the local file after a push.
208
+
209
+ Keeps the user's nodes/edges untouched; only refreshes server-owned metadata so
210
+ the next push updates (not duplicates) and the stale-write guard stays accurate.
211
+ """
212
+ for key in ("id", "created_at", "modified_at"):
213
+ if server_record.get(key) is not None:
214
+ doc[key] = server_record[key]
215
+ path.write_text(json.dumps(doc, indent=2), encoding="utf-8")
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Scripts. Unlike workflows, a script's code file can't carry a server id, so
220
+ # `push script` identifies its target by filename and lets the CLI decide
221
+ # create-vs-update (check-then-confirm). The language is inferred from the file
222
+ # extension; the server owns id/version/owner/content_type.
223
+ # ---------------------------------------------------------------------------
224
+
225
+
226
+ class ScriptFileNotFound(Exception):
227
+ """No script file matched the given path-or-name (or a bare name matched several)."""
228
+
229
+ def __init__(self, ref: str, looked: list[Path], matches: list[Path] | None = None) -> None:
230
+ super().__init__(f"No script file for '{ref}'.")
231
+ self.ref = ref
232
+ self.looked = looked
233
+ self.matches = matches or [] # >1 hit when a bare name is ambiguous
234
+
235
+
236
+ def resolve_script_file(workspace: Path, ref: str) -> Path:
237
+ """Find a script file by explicit path, or by name under <ws>/scripts/.
238
+
239
+ Tries: the ref as a path; then <ws>/scripts/{ref}. If ref has no extension,
240
+ globs <ws>/scripts/{ref}.* and returns it only when exactly one file matches
241
+ (raises with the candidates when several do).
242
+ """
243
+ direct = Path(ref)
244
+ if direct.is_file():
245
+ return direct
246
+
247
+ folder = workspace / "scripts"
248
+ exact = folder / ref
249
+ looked = [direct, exact]
250
+ if exact.is_file():
251
+ return exact
252
+
253
+ if "." not in ref and folder.is_dir():
254
+ matches = sorted(p for p in folder.glob(f"{ref}.*") if p.is_file())
255
+ if len(matches) == 1:
256
+ return matches[0]
257
+ if len(matches) > 1:
258
+ raise ScriptFileNotFound(ref, looked, matches=matches)
259
+
260
+ raise ScriptFileNotFound(ref, looked)
261
+
262
+
263
+ def script_push_spec(
264
+ path: Path, name_override: str | None = None, language_override: str | None = None
265
+ ) -> dict:
266
+ """Turn a local file into a create payload + the name the server will store it as.
267
+
268
+ Returns {name, language, content, server_name}. `name` is the bare stem sent to
269
+ the create endpoint; `server_name` is "{name}.{ext}" — what the server stores and
270
+ what `find_script_by_name` matches on. Raises ValueError (with a fix hint) when
271
+ the extension is unmapped, the name is illegal, or the file is empty.
272
+ """
273
+ try:
274
+ content = path.read_text(encoding="utf-8-sig") # strip a BOM if present
275
+ except OSError as exc:
276
+ raise ValueError(f"Couldn't read {path}: {exc}")
277
+
278
+ name = name_override or path.stem
279
+ ext = path.suffix.lstrip(".").lower()
280
+
281
+ if language_override:
282
+ language = language_override.lower()
283
+ else:
284
+ language = _EXT_TO_LANGUAGE.get(ext)
285
+ if not language:
286
+ langs = ", ".join(sorted(set(_EXT_TO_LANGUAGE.values())))
287
+ shown = f".{ext}" if ext else "(no extension)"
288
+ raise ValueError(
289
+ f"Can't tell the language from '{shown}'. Pass --language <lang> "
290
+ f"(one of: {langs})."
291
+ )
292
+
293
+ if language not in _LANGUAGE_EXT:
294
+ raise ValueError(
295
+ f"Unsupported language '{language}'. Supported: "
296
+ f"{', '.join(sorted(_LANGUAGE_EXT))}."
297
+ )
298
+
299
+ if not _SCRIPT_NAME_RE.match(name):
300
+ raise ValueError(
301
+ f"Script name '{name}' isn't allowed — the server accepts only letters, "
302
+ f"numbers, '_' and '-' (no dots or spaces). Pass --name <name> to override."
303
+ )
304
+
305
+ if not content.strip():
306
+ raise ValueError("Script content is empty — nothing to push.")
307
+
308
+ server_name = f"{name}.{_LANGUAGE_EXT[language]}"
309
+ return {"name": name, "language": language, "content": content, "server_name": server_name}
310
+
311
+
312
+ def infer_script_language(path: Path) -> str | None:
313
+ """Best-effort language keyword from a file path's extension (None if unknown)."""
314
+ return _EXT_TO_LANGUAGE.get(path.suffix.lstrip(".").lower())
315
+
316
+
317
+ def _render_script_header(language: str) -> str:
318
+ """The scaffold doc lines wrapped in `language`'s comment syntax ('' if none)."""
319
+ block = _BLOCK_COMMENT.get(language)
320
+ if block:
321
+ open_, close_ = block
322
+ body = "\n".join(f" {line}".rstrip() for line in _SCRIPT_DOC_LINES)
323
+ return f"{open_}\n{body}\n{close_}\n\n"
324
+ prefix = _LINE_COMMENT.get(language)
325
+ if not prefix:
326
+ return "" # e.g. json — no comment syntax
327
+ body = "\n".join(f"{prefix} {line}".rstrip() for line in _SCRIPT_DOC_LINES)
328
+ return f"{body}\n\n"
329
+
330
+
331
+ def script_scaffold(name: str, language: str) -> tuple[str, str]:
332
+ """Return (filename, file_content) for `sagex new script`.
333
+
334
+ filename is "<name>.<ext>" for the language's server-side extension; content is a
335
+ starter header (in the language's comment style) documenting how parameters and
336
+ secrets reach the script. Raises ValueError on an illegal name or unknown language.
337
+ """
338
+ language = language.lower()
339
+ if language not in _LANGUAGE_EXT:
340
+ raise ValueError(
341
+ f"Unsupported language '{language}'. Supported: {', '.join(sorted(_LANGUAGE_EXT))}."
342
+ )
343
+ if not _SCRIPT_NAME_RE.match(name):
344
+ raise ValueError(
345
+ f"Script name '{name}' isn't allowed — the server accepts only letters, "
346
+ f"numbers, '_' and '-' (no dots or spaces)."
347
+ )
348
+ filename = f"{name}.{_LANGUAGE_EXT[language]}"
349
+ content = _render_script_header(language) or "{}\n" # json has no comments
350
+ return filename, content