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.
- tensa/__init__.py +36 -0
- tensa/__main__.py +16 -0
- tensa/api/__init__.py +6 -0
- tensa/api/_run_as_job.py +207 -0
- tensa/api/app.py +515 -0
- tensa/api/error_mapping.py +231 -0
- tensa/api/routes/__init__.py +0 -0
- tensa/api/routes/bundle.py +424 -0
- tensa/api/routes/cases.py +391 -0
- tensa/api/routes/clone.py +431 -0
- tensa/api/routes/cpf.py +377 -0
- tensa/api/routes/disturbances.py +203 -0
- tensa/api/routes/eig.py +422 -0
- tensa/api/routes/elements.py +549 -0
- tensa/api/routes/jobs.py +302 -0
- tensa/api/routes/pflow.py +187 -0
- tensa/api/routes/pmu.py +430 -0
- tensa/api/routes/profiles.py +559 -0
- tensa/api/routes/reports.py +214 -0
- tensa/api/routes/se.py +375 -0
- tensa/api/routes/sessions.py +139 -0
- tensa/api/routes/snapshot.py +718 -0
- tensa/api/routes/sweep.py +280 -0
- tensa/api/routes/tds.py +231 -0
- tensa/api/routes/workspace.py +317 -0
- tensa/api/routes/ws.py +357 -0
- tensa/api/schemas.py +1380 -0
- tensa/cli.py +446 -0
- tensa/core/__init__.py +8 -0
- tensa/core/bundle.py +893 -0
- tensa/core/clone_manager.py +697 -0
- tensa/core/clone_writers/__init__.py +147 -0
- tensa/core/clone_writers/clone_write_index.json +3119 -0
- tensa/core/clone_writers/dyr_writer.py +216 -0
- tensa/core/clone_writers/raw_writer.py +42 -0
- tensa/core/clone_writers/xlsx_writer.py +117 -0
- tensa/core/connectivity_result.py +63 -0
- tensa/core/cpf_result.py +79 -0
- tensa/core/disturbance.py +150 -0
- tensa/core/eig_result.py +78 -0
- tensa/core/errors.py +398 -0
- tensa/core/examples.py +103 -0
- tensa/core/jobs.py +357 -0
- tensa/core/psse_writer.py +406 -0
- tensa/core/report.py +551 -0
- tensa/core/se_result.py +83 -0
- tensa/core/session.py +1930 -0
- tensa/core/snapshot.py +462 -0
- tensa/core/stream.py +778 -0
- tensa/core/sweep.py +287 -0
- tensa/core/worker.py +1322 -0
- tensa/core/wrapper.py +4953 -0
- tensa/mcp_server.py +226 -0
- tensa/py.typed +0 -0
- tensa/security/__init__.py +8 -0
- tensa/security/middleware.py +99 -0
- tensa/security/paths.py +251 -0
- tensa/static/assets/index-BAsS38eI.css +1 -0
- tensa/static/assets/index-CNaI1he_.js +155 -0
- tensa/static/assets/index-CNaI1he_.js.map +1 -0
- tensa/static/favicon.svg +7 -0
- tensa/static/index.html +36 -0
- tensa/static/logo.svg +7 -0
- tensa-0.4.0.dist-info/METADATA +777 -0
- tensa-0.4.0.dist-info/RECORD +67 -0
- tensa-0.4.0.dist-info/WHEEL +4 -0
- 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
tensa/api/_run_as_job.py
ADDED
|
@@ -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)
|