tensa 0.4.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.
Files changed (67) hide show
  1. tensa/__init__.py +36 -0
  2. tensa/__main__.py +16 -0
  3. tensa/api/__init__.py +6 -0
  4. tensa/api/_run_as_job.py +207 -0
  5. tensa/api/app.py +515 -0
  6. tensa/api/error_mapping.py +231 -0
  7. tensa/api/routes/__init__.py +0 -0
  8. tensa/api/routes/bundle.py +424 -0
  9. tensa/api/routes/cases.py +391 -0
  10. tensa/api/routes/clone.py +431 -0
  11. tensa/api/routes/cpf.py +377 -0
  12. tensa/api/routes/disturbances.py +203 -0
  13. tensa/api/routes/eig.py +422 -0
  14. tensa/api/routes/elements.py +549 -0
  15. tensa/api/routes/jobs.py +302 -0
  16. tensa/api/routes/pflow.py +187 -0
  17. tensa/api/routes/pmu.py +430 -0
  18. tensa/api/routes/profiles.py +559 -0
  19. tensa/api/routes/reports.py +214 -0
  20. tensa/api/routes/se.py +375 -0
  21. tensa/api/routes/sessions.py +139 -0
  22. tensa/api/routes/snapshot.py +718 -0
  23. tensa/api/routes/sweep.py +280 -0
  24. tensa/api/routes/tds.py +231 -0
  25. tensa/api/routes/workspace.py +317 -0
  26. tensa/api/routes/ws.py +357 -0
  27. tensa/api/schemas.py +1380 -0
  28. tensa/cli.py +446 -0
  29. tensa/core/__init__.py +8 -0
  30. tensa/core/bundle.py +893 -0
  31. tensa/core/clone_manager.py +697 -0
  32. tensa/core/clone_writers/__init__.py +147 -0
  33. tensa/core/clone_writers/clone_write_index.json +3119 -0
  34. tensa/core/clone_writers/dyr_writer.py +216 -0
  35. tensa/core/clone_writers/raw_writer.py +42 -0
  36. tensa/core/clone_writers/xlsx_writer.py +117 -0
  37. tensa/core/connectivity_result.py +63 -0
  38. tensa/core/cpf_result.py +79 -0
  39. tensa/core/disturbance.py +150 -0
  40. tensa/core/eig_result.py +78 -0
  41. tensa/core/errors.py +398 -0
  42. tensa/core/examples.py +103 -0
  43. tensa/core/jobs.py +357 -0
  44. tensa/core/psse_writer.py +406 -0
  45. tensa/core/report.py +551 -0
  46. tensa/core/se_result.py +83 -0
  47. tensa/core/session.py +1930 -0
  48. tensa/core/snapshot.py +462 -0
  49. tensa/core/stream.py +778 -0
  50. tensa/core/sweep.py +287 -0
  51. tensa/core/worker.py +1322 -0
  52. tensa/core/wrapper.py +4953 -0
  53. tensa/mcp_server.py +226 -0
  54. tensa/py.typed +0 -0
  55. tensa/security/__init__.py +8 -0
  56. tensa/security/middleware.py +99 -0
  57. tensa/security/paths.py +251 -0
  58. tensa/static/assets/index-BAsS38eI.css +1 -0
  59. tensa/static/assets/index-CNaI1he_.js +155 -0
  60. tensa/static/assets/index-CNaI1he_.js.map +1 -0
  61. tensa/static/favicon.svg +7 -0
  62. tensa/static/index.html +36 -0
  63. tensa/static/logo.svg +7 -0
  64. tensa-0.4.0.dist-info/METADATA +777 -0
  65. tensa-0.4.0.dist-info/RECORD +67 -0
  66. tensa-0.4.0.dist-info/WHEEL +4 -0
  67. tensa-0.4.0.dist-info/entry_points.txt +2 -0
tensa/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """tensa — web-based GUI substrate for the ANDES power-system simulator.
2
+
3
+ This is Phase A: the Python wrapper around ANDES + FastAPI HTTP/WebSocket
4
+ surface. The substrate is independently usable — agents, SDKs, and curl can
5
+ drive ANDES through it without any UI. v0.1+ adds a React UI in a separate
6
+ plan; this package is the foundation it sits on.
7
+
8
+ Trust model (canonical statement; AGENTS.md links here)
9
+ -------------------------------------------------------
10
+ v0.1 trust model:
11
+
12
+ * The local OS user is trusted to execute arbitrary code. Case files contain
13
+ Python expressions evaluated by ANDES at parse time, and the local user is
14
+ the only authorized actor.
15
+ * Loopback web origins from random browser tabs are NOT trusted. Defended via
16
+ Host/Origin pure-ASGI middleware + precise CORS allow-list (no wildcards,
17
+ no ``null``, no extension origins).
18
+ * There is NO authentication. The server binds to loopback by default; any
19
+ process on the local machine can reach the API. Binding to a non-loopback
20
+ interface exposes the API to the whole network and emits a stderr warning.
21
+ * Third-party case files are NOT trusted by the system but ARE trusted by the
22
+ user when they choose to load them — analogous to opening an .xlsx in
23
+ Excel. ANDES's secondary file-read machinery (``addfile=``, dynamic-model
24
+ ``path=``) is logged via ``sys.audit`` as best-effort visibility (Python-level
25
+ only — does not catch C-extension reads from numpy/pandas/openpyxl). For
26
+ actual workspace enforcement, kernel-level controls (Linux seccomp,
27
+ Landlock) are required and are deferred to the SaaS phase.
28
+ * On Windows, path canonicalization is best-effort: the workspace boundary is
29
+ not enforced for ANDES-internal reads, and a stderr warning is emitted at
30
+ startup.
31
+
32
+ See ``AGENTS.md`` and ``docs/plans/2026-05-07-001-feat-tensa-phase-a-substrate-plan.md``
33
+ for the full design.
34
+ """
35
+
36
+ __version__ = "0.1.0.dev0"
tensa/__main__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Entry point for ``python -m tensa``.
2
+
3
+ Delegates to the Typer CLI defined in ``tensa.cli``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from tensa.cli import app
9
+
10
+
11
+ def main() -> None:
12
+ app()
13
+
14
+
15
+ if __name__ == "__main__":
16
+ main()
tensa/api/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """HTTP / WebSocket API surface.
2
+
3
+ ``app.py`` builds the FastAPI application. ``schemas.py`` defines the
4
+ Pydantic v2 request / response models. ``routes/`` contains one router per
5
+ resource family.
6
+ """
@@ -0,0 +1,207 @@
1
+ r"""``_run_as_job`` — the per-routine job-lifecycle context manager (v3.1 Unit 5a).
2
+
3
+ This is the migration target Unit 5b wraps every routine route's
4
+ ``mgr.invoke`` call in::
5
+
6
+ async with _run_as_job(mgr, session_id, "pflow", request.model_dump()) as job_id:
7
+ result = await mgr.invoke(session_id, "run_pflow", ...)
8
+ return {**result, "job_id": job_id}
9
+
10
+ It drives the registry lifecycle around the wrapped block:
11
+
12
+ register_job(pending) -> mark_running -> mark_done (success)
13
+ \-> mark_failed (exception)
14
+
15
+ and broadcasts the per-session ``/jobs/events`` WS envelope on each
16
+ transition so connected activity panels update live.
17
+
18
+ Scope (feasibility F3): ``_run_as_job`` covers ``mgr.invoke`` ONLY. Streaming
19
+ TDS (``start_streaming_run``) and sweeps (``start_sweep``) are long-lived
20
+ background tasks whose ``mark_done`` / ``mark_failed`` fire from their own
21
+ drivers; Unit 5c wires those via ``register_streaming_job`` /
22
+ ``register_sweep_job``. Do NOT wrap those calls in this context manager.
23
+
24
+ Exception handling (adversarial F4): the catch clause is ``except Exception``,
25
+ NOT ``BaseException``. ``KeyboardInterrupt`` / ``SystemExit`` /
26
+ ``asyncio.CancelledError`` are deliberately left to propagate untouched for the
27
+ server's lifecycle handling. On a caught ``Exception`` we synthesize a 500
28
+ ``ProblemDetails`` (category ``WorkerInternalError``, ``detail=str(exc)``),
29
+ ``mark_failed`` the job, broadcast the transition, THEN re-raise. This closes
30
+ the "stuck ``running`` because the exception escaped the registry transitions"
31
+ trap that the liveness sweeper would NOT catch — the worker is still alive (it
32
+ raised and returned to idle), so the dead-worker check never fires.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import contextlib
38
+ import time
39
+ from collections.abc import AsyncIterator
40
+ from typing import TYPE_CHECKING, Any
41
+
42
+ from tensa.api.error_mapping import WORKER_ERROR_HTTP_MAP
43
+ from tensa.core.errors import WorkerDiedError
44
+ from tensa.core.session import WORKER_DIED_CATEGORY, WorkerError
45
+
46
+ if TYPE_CHECKING:
47
+ from tensa.core.jobs import JobKind, _JobRegistry
48
+ from tensa.core.session import SessionManager
49
+
50
+ # Wire category stamped onto the synthesized ProblemDetails when a NON-WorkerError
51
+ # exception escapes the wrapped block. Distinct from the liveness sweeper's
52
+ # ``WorkerDied`` (the worker is alive here; it raised).
53
+ WORKER_INTERNAL_CATEGORY = "WorkerInternalError"
54
+
55
+
56
+ def _internal_error_problem(kind: JobKind, exc: Exception) -> dict[str, Any]:
57
+ """Synthesize the ProblemDetails for an exception escaping the block.
58
+
59
+ For a :class:`WorkerError` the record carries the REAL ``category`` and the
60
+ mapped HTTP status (e.g. ``no-case-loaded`` → 409, ``ElementHasDependentsError``
61
+ → 422) so the activity-panel failure log and the failure-signature
62
+ coalescing key off the true error, not a blanket ``WorkerInternalError``/500.
63
+ A business-conflict like a blocked delete is thus recorded as its own 422
64
+ category rather than masquerading as a 500 server error. Everything else
65
+ (genuinely unexpected exceptions) keeps the synthesized 500.
66
+ """
67
+ if isinstance(exc, WorkerDiedError):
68
+ # The worker subprocess crashed mid-RPC (torn pipe). Stamp the same
69
+ # ``WorkerDied`` category + 503 + ``reload-case`` recovery the HTTP
70
+ # handler returns so the activity-panel failure record matches the
71
+ # response the user sees, rather than masquerading as a generic 500.
72
+ return {
73
+ "type": "about:blank",
74
+ "title": "Service Unavailable",
75
+ "status": 503,
76
+ "category": WORKER_DIED_CATEGORY,
77
+ "detail": exc.detail,
78
+ "recovery": {"kind": "reload-case", "label": "Reload the case"},
79
+ }
80
+ if isinstance(exc, WorkerError):
81
+ category = exc.category or WORKER_INTERNAL_CATEGORY
82
+ http_status = WORKER_ERROR_HTTP_MAP.get(category, 500)
83
+ return {
84
+ "type": "about:blank",
85
+ "title": "Internal Server Error" if http_status >= 500 else "Request Failed",
86
+ "status": http_status,
87
+ "category": category,
88
+ "detail": exc.detail,
89
+ "recovery": None,
90
+ }
91
+ return {
92
+ "type": "about:blank",
93
+ "title": "Internal Server Error",
94
+ "status": 500,
95
+ "category": WORKER_INTERNAL_CATEGORY,
96
+ "detail": str(exc),
97
+ "recovery": None,
98
+ }
99
+
100
+
101
+ @contextlib.asynccontextmanager
102
+ async def _run_as_job(
103
+ mgr: SessionManager,
104
+ session_id: str,
105
+ kind: JobKind,
106
+ request_summary: dict[str, Any] | None = None,
107
+ *,
108
+ can_cancel: bool = False,
109
+ result_ref: str | None = None,
110
+ use_global_registry: bool = False,
111
+ ) -> AsyncIterator[str]:
112
+ """Register a job, run the wrapped block, and drive its lifecycle.
113
+
114
+ Yields the new ``job_id``. The block performs the actual ``mgr.invoke``.
115
+
116
+ - On normal exit: ``mark_done`` (with ``result_ref`` if supplied).
117
+ - On a caught ``Exception``: ``mark_failed`` with a synthesized 500
118
+ ``ProblemDetails`` then re-raise. ``BaseException`` (KeyboardInterrupt /
119
+ SystemExit / CancelledError) propagates WITHOUT marking the job — those
120
+ are lifecycle signals, not job failures.
121
+
122
+ Every transition (running / done / failed) is broadcast to the session's
123
+ ``/jobs/events`` subscribers via ``mgr.broadcast_job_event``.
124
+
125
+ ``can_cancel`` defaults to ``False`` because every ``mgr.invoke`` routine
126
+ is synchronous from the caller's view (PF / EIG / CPF / SE) — the cancel
127
+ affordance belongs to streaming / sweep jobs (Unit 5c). Pass ``True`` only
128
+ if a future invoke-backed job grows a cooperative-abort path.
129
+
130
+ ``use_global_registry`` (KTD-20) routes the record into the manager-wide
131
+ ``global_job_registry`` rather than the per-session registry. Session-
132
+ MUTATING jobs — snapshot restore, bundle import, case reload — set this so
133
+ the record survives the session it mutated INTO being replaced. The record
134
+ still surfaces in the originating session's activity panel because
135
+ ``list_session_jobs`` / ``get_session_job`` span both registries; the WS
136
+ broadcast targets the same ``session_id`` either way.
137
+ """
138
+ registry = (
139
+ mgr.global_job_registry
140
+ if use_global_registry
141
+ else mgr.session_job_registry(session_id)
142
+ )
143
+ # Stamp the originating session onto global-registry records so the
144
+ # per-session HTTP surface (list/get/cancel) can filter the shared registry
145
+ # to this session — otherwise every session would see (and be able to
146
+ # cancel) every other session's session-mutating jobs (cross-session leak).
147
+ job_id = registry.register_job(
148
+ kind=kind,
149
+ can_cancel=can_cancel,
150
+ request_summary=request_summary or {},
151
+ origin_session_id=session_id if use_global_registry else None,
152
+ )
153
+ _broadcast(mgr, session_id, registry, job_id)
154
+
155
+ registry.mark_running(job_id)
156
+ _broadcast(mgr, session_id, registry, job_id)
157
+
158
+ try:
159
+ yield job_id
160
+ except Exception as exc:
161
+ # NOT BaseException — preserve KeyboardInterrupt / SystemExit /
162
+ # asyncio.CancelledError for the server's lifecycle handling. Marking
163
+ # failed here is what keeps the job from being stuck ``running`` when
164
+ # the exception escapes; the liveness sweeper would NOT catch it (the
165
+ # worker is alive — it raised and returned to idle).
166
+ # ``mark_failed`` may coalesce this failure into a prior same-signature
167
+ # record (deleting THIS job_id); broadcast the SURVIVOR it returns so
168
+ # the terminal transition still reaches WS subscribers instead of being
169
+ # dropped (a re-read of the deleted id would yield None).
170
+ problem = _internal_error_problem(kind, exc)
171
+ # Snapshot the (still-``running``) record BEFORE mark_failed coalesces it
172
+ # away, so we can synthesize a terminal envelope for THIS id if it gets
173
+ # deleted. Without this, a client that saw ``job_id`` go ``running`` over
174
+ # the WS would never hear it reach a terminal state when the failure is
175
+ # coalesced into a prior id (the survivor is broadcast under a DIFFERENT
176
+ # id) — its in-flight activity pill then spins forever (the "load case
177
+ # stuck running" bug for a repeated same-signature failure).
178
+ pre = registry.get_job(job_id)
179
+ survivor_id = registry.mark_failed(job_id, problem=problem)
180
+ if survivor_id != job_id and pre is not None:
181
+ pre.status = "failed"
182
+ pre.problem = problem
183
+ pre.ended_at = pre.updated_at = time.monotonic()
184
+ mgr.broadcast_job_event(session_id, pre)
185
+ _broadcast(mgr, session_id, registry, survivor_id)
186
+ raise
187
+ else:
188
+ registry.mark_done(job_id, result_ref=result_ref)
189
+ _broadcast(mgr, session_id, registry, job_id)
190
+
191
+
192
+ def _broadcast(
193
+ mgr: SessionManager,
194
+ session_id: str,
195
+ registry: _JobRegistry,
196
+ job_id: str,
197
+ ) -> None:
198
+ """Broadcast the current state of ``job_id`` to the session's WS subscribers.
199
+
200
+ Re-reads the record from its owning ``registry`` (the registry coalesces
201
+ failures by signature, so the post-transition record is the authoritative
202
+ one) and pushes its envelope to the originating session's subscribers. A
203
+ no-op when the record has been coalesced away.
204
+ """
205
+ record = registry.get_job(job_id)
206
+ if record is not None:
207
+ mgr.broadcast_job_event(session_id, record)