orgm-bt 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 (122) hide show
  1. app/__init__.py +10 -0
  2. app/adapters/__init__.py +1 -0
  3. app/api.py +607 -0
  4. app/api_models.py +189 -0
  5. app/cli.py +522 -0
  6. app/config.py +96 -0
  7. app/db/__init__.py +3 -0
  8. app/db/alembic/env.py +95 -0
  9. app/db/alembic/versions/20250101000000_create_calc_api_tables.py +212 -0
  10. app/db/alembic/versions/90c3cdb1d7d5_create_empresas_and_proyectos_tables.py +56 -0
  11. app/db/alembic/versions/c6020725a319_seed_default_empresa_orgm.py +54 -0
  12. app/db/database.py +31 -0
  13. app/db/models.py +46 -0
  14. app/default_assets/memoria.svg +72 -0
  15. app/default_data/CEPM.json +1 -0
  16. app/default_data/MOPC.json +6242 -0
  17. app/default_data/NEC.json +1 -0
  18. app/default_data/aires_predeterminados.json +806 -0
  19. app/default_data/breakers.json +24 -0
  20. app/default_data/cargas_predeterminadas.json +821 -0
  21. app/default_data/categorias.json +10 -0
  22. app/default_data/circuitos_panel.json +52 -0
  23. app/default_data/conexiones_cables.json +190 -0
  24. app/default_data/estimacion_area.json +216 -0
  25. app/default_data/factores_demanda.json +961 -0
  26. app/default_data/motores_predeterminados.json +1055 -0
  27. app/default_data/nec_250_66.json +101 -0
  28. app/default_data/nema.json +36 -0
  29. app/default_data/norma.json +65 -0
  30. app/default_data/panel.json +178 -0
  31. app/default_data/proposito.json +21 -0
  32. app/du/alimentador.py +576 -0
  33. app/du/dibujante.py +400 -0
  34. app/du/du.py +997 -0
  35. app/du/du_json.py +433 -0
  36. app/importers/__init__.py +1 -0
  37. app/importers/csv_paneles.py +308 -0
  38. app/importers/csv_parser.py +156 -0
  39. app/importers/entity_detector.py +242 -0
  40. app/importers/errors.py +235 -0
  41. app/importers/hierarchy_builder.py +181 -0
  42. app/importers/mapping.py +213 -0
  43. app/local_config.py +94 -0
  44. app/models.py +105 -0
  45. app/paneles_editor.py +1789 -0
  46. app/runtime_paths.py +43 -0
  47. app/schemas.py +73 -0
  48. app/services/__init__.py +3 -0
  49. app/services/generador_pdf.py +6363 -0
  50. app/services/job_store.py +537 -0
  51. app/services/job_worker.py +457 -0
  52. app/services/local_operations.py +594 -0
  53. app/services/logo_url.py +83 -0
  54. app/services/project_store.py +301 -0
  55. app/services/redaction.py +42 -0
  56. app/services/remote_operations.py +445 -0
  57. app/services/validator.py +365 -0
  58. app/services/webdav_storage.py +277 -0
  59. app/static/css/templates/alimentadores.css +76 -0
  60. app/static/css/templates/cuadros-base.css +242 -0
  61. app/static/css/templates/electrodo.css +105 -0
  62. app/static/css/templates/equipos.css +117 -0
  63. app/static/css/templates/memoria.css +369 -0
  64. app/static/css/templates/menu.css +127 -0
  65. app/static/css/templates/mpm.css +148 -0
  66. app/static/css/templates/paneles.css +140 -0
  67. app/static/css/templates/print-content.css +74 -0
  68. app/static/css/templates/print-menu.css +38 -0
  69. app/static/css/templates/reportes-common.css +104 -0
  70. app/static/css/templates/trf.css +211 -0
  71. app/static/js/menu-reportes.js +261 -0
  72. app/templates/memoria-calculo.html +556 -0
  73. app/templates/menu.html +40 -0
  74. app/templates/template-alimentadores.html +91 -0
  75. app/templates/template-cuadro-distribucion.html +334 -0
  76. app/templates/template-electrodo.html +66 -0
  77. app/templates/template-equipos.html +491 -0
  78. app/templates/template-memoria.html +2378 -0
  79. app/templates/template-mpm.html +248 -0
  80. app/templates/template-paneles.html +101 -0
  81. app/templates/template-trf-standalone.html +89 -0
  82. app/templates/template-trf.html +604 -0
  83. app/utils/__init__.py +1 -0
  84. app/utils/aireacondicionado.py +57 -0
  85. app/utils/cables.py +2053 -0
  86. app/utils/carga.py +1897 -0
  87. app/utils/chromium_pdf.py +226 -0
  88. app/utils/circuitos.py +489 -0
  89. app/utils/comercio.py +28 -0
  90. app/utils/conduit_rules.py +178 -0
  91. app/utils/device_type_catalog.py +231 -0
  92. app/utils/electrodo.py +503 -0
  93. app/utils/equipos.py +1421 -0
  94. app/utils/factores_demanda.py +362 -0
  95. app/utils/firma.py +116 -0
  96. app/utils/load_semantics.py +53 -0
  97. app/utils/logger.py +296 -0
  98. app/utils/motores.py +69 -0
  99. app/utils/mpm.py +1504 -0
  100. app/utils/norma.py +331 -0
  101. app/utils/panel.py +4024 -0
  102. app/utils/pdf_output.py +45 -0
  103. app/utils/print_pdf.py +122 -0
  104. app/utils/residencial.py +565 -0
  105. app/utils/tablas.py +447 -0
  106. app/utils/trf.py +1690 -0
  107. app/utils/weasyprint_fetcher.py +89 -0
  108. app/utils/weasyprint_pdf.py +96 -0
  109. app/validators/__init__.py +1 -0
  110. app/validators/paneles_integrity.py +245 -0
  111. app/validators/paneles_schema.py +384 -0
  112. app/validators/paneles_validator.py +194 -0
  113. app/workspace.py +543 -0
  114. local.py +998 -0
  115. main.py +2904 -0
  116. orgm_bt-0.4.0.dist-info/METADATA +634 -0
  117. orgm_bt-0.4.0.dist-info/RECORD +122 -0
  118. orgm_bt-0.4.0.dist-info/WHEEL +5 -0
  119. orgm_bt-0.4.0.dist-info/entry_points.txt +2 -0
  120. orgm_bt-0.4.0.dist-info/top_level.txt +4 -0
  121. storage/__init__.py +3 -0
  122. storage/s3.py +191 -0
app/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ calc-bt Backend
3
+ Sistema backend FastAPI para cálculos eléctricos de baja tensión (NEC)
4
+
5
+ Fases implementadas:
6
+ - Fase 1: Importación de datos TXT/CSV
7
+ - Fase 2: Balance de cargas con algoritmo greedy
8
+ """
9
+
10
+ __version__ = "0.2.0"
@@ -0,0 +1 @@
1
+ """Adapters de paneles.json a modelos internos del motor."""
app/api.py ADDED
@@ -0,0 +1,607 @@
1
+ """HTTP API for persisted project configuration and serialized remote jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import stat
9
+ import tempfile
10
+ import uuid
11
+ from contextlib import asynccontextmanager
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path, PurePosixPath
14
+ from typing import Annotated, Any, Literal, Mapping
15
+
16
+ from fastapi import FastAPI, HTTPException, Query, Response, status
17
+ from fastapi import Path as ApiPath
18
+ from fastapi.responses import FileResponse
19
+
20
+ from app.api_models import (
21
+ DuRequest,
22
+ EmptyActionRequest,
23
+ HealthResponse,
24
+ InitRequest,
25
+ JobAccepted,
26
+ JobLogsResponse,
27
+ JobResponse,
28
+ ProjectCreate,
29
+ ProjectPatch,
30
+ ProjectResponse,
31
+ RemoteDirectoryResponse,
32
+ VersionResponse,
33
+ )
34
+ from app.config import Settings
35
+ from app.services.job_store import JobManifest, JobStatus, JobStore
36
+ from app.services.job_worker import JobWorker
37
+ from app.services.project_store import ProjectStore, normalize_webdav_path
38
+ from app.services.redaction import redact_text
39
+ from app.services.remote_operations import RemoteOperations
40
+ from app.services.webdav_storage import WebDAVStorage
41
+
42
+ _LOG_TAIL_BYTES = 16 * 1024
43
+ _MANIFEST_ERROR_BYTES = 4 * 1024
44
+ _ACTIONS = frozenset({"init", "calculate", "du", "print-pdf", "finalize"})
45
+ _UUID4_PATTERN = (
46
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
47
+ )
48
+ UUID4Path = Annotated[str, ApiPath(pattern=_UUID4_PATTERN)]
49
+ NumericVersionPath = Annotated[str, ApiPath(pattern=r"^[0-9]+$")]
50
+
51
+
52
+ class _UnavailableWebDAVStorage:
53
+ """Safe degraded default until deployment credentials are configured."""
54
+
55
+ def check_connection(self) -> bool:
56
+ return False
57
+
58
+ def __getattr__(self, _: str) -> Any:
59
+ raise RuntimeError("WebDAV storage is not configured")
60
+
61
+
62
+ def create_app(
63
+ *,
64
+ settings: Settings | None = None,
65
+ project_store: ProjectStore | None = None,
66
+ storage: Any | None = None,
67
+ job_store: JobStore | None = None,
68
+ executor: RemoteOperations | None = None,
69
+ worker: JobWorker | None = None,
70
+ ) -> FastAPI:
71
+ """Build an injectable API application with one managed job worker."""
72
+ runtime_settings = settings or Settings()
73
+ projects = project_store or ProjectStore(
74
+ runtime_settings.CALC_DATA_DIR / "local.json"
75
+ )
76
+ if storage is None:
77
+ try:
78
+ storage = WebDAVStorage(runtime_settings)
79
+ except ValueError:
80
+ storage = _UnavailableWebDAVStorage()
81
+ jobs = job_store or (
82
+ worker.store
83
+ if worker is not None
84
+ else JobStore(
85
+ runtime_settings.CALC_JOB_DIR,
86
+ retention_days=runtime_settings.CALC_JOB_RETENTION_DAYS,
87
+ )
88
+ )
89
+ remote = executor or RemoteOperations(
90
+ projects, storage, jobs, settings=runtime_settings
91
+ )
92
+ job_worker = worker or JobWorker(
93
+ jobs,
94
+ remote.run_stage,
95
+ remote.upload_stage,
96
+ success_stage=remote.record_success,
97
+ failure_stage=remote.record_failure,
98
+ error_sanitizer=remote.sanitize_error,
99
+ )
100
+
101
+ @asynccontextmanager
102
+ async def lifespan(app: FastAPI):
103
+ await asyncio.to_thread(projects.ensure)
104
+ await job_worker.start()
105
+ try:
106
+ yield
107
+ finally:
108
+ await job_worker.stop()
109
+
110
+ app = FastAPI(title=runtime_settings.PROJECT_NAME, lifespan=lifespan)
111
+ app.state.settings = runtime_settings
112
+ app.state.project_store = projects
113
+ app.state.storage = storage
114
+ app.state.job_store = jobs
115
+ app.state.executor = remote
116
+ app.state.worker = job_worker
117
+ prefix = runtime_settings.API_V1_PREFIX.rstrip("/")
118
+
119
+ @app.get(f"{prefix}/healthz", response_model=HealthResponse)
120
+ async def healthz() -> HealthResponse:
121
+ data_ok, jobs_ok = await asyncio.gather(
122
+ asyncio.to_thread(_directory_writable, runtime_settings.CALC_DATA_DIR),
123
+ asyncio.to_thread(_directory_writable, runtime_settings.CALC_JOB_DIR),
124
+ )
125
+ try:
126
+ webdav_ok = await asyncio.to_thread(storage.check_connection)
127
+ except Exception:
128
+ webdav_ok = False
129
+ return HealthResponse(
130
+ status="ok" if data_ok and jobs_ok and webdav_ok else "degraded",
131
+ data_writable=data_ok,
132
+ jobs_writable=jobs_ok,
133
+ webdav=webdav_ok,
134
+ )
135
+
136
+ @app.get(f"{prefix}/projects", response_model=list[ProjectResponse])
137
+ async def list_projects() -> list[ProjectResponse]:
138
+ try:
139
+ return [
140
+ _project_response(item)
141
+ for item in await asyncio.to_thread(projects.list)
142
+ ]
143
+ except Exception:
144
+ raise HTTPException(
145
+ status_code=500, detail="Project configuration is unavailable"
146
+ ) from None
147
+
148
+ @app.get(f"{prefix}/folders", response_model=list[RemoteDirectoryResponse])
149
+ async def list_folders(
150
+ path: Annotated[str, Query(min_length=1)] = "Proyectos",
151
+ ) -> list[RemoteDirectoryResponse]:
152
+ try:
153
+ canonical_path = normalize_webdav_path(path)
154
+ if not PurePosixPath(canonical_path).is_relative_to(
155
+ PurePosixPath("Proyectos")
156
+ ):
157
+ raise ValueError("Folder path must be within Proyectos")
158
+ entries = await asyncio.to_thread(storage.list_directory, canonical_path)
159
+ except ValueError as error:
160
+ raise HTTPException(status_code=422, detail=str(error)) from None
161
+ except Exception:
162
+ raise HTTPException(
163
+ status_code=502, detail="Could not list remote folders"
164
+ ) from None
165
+ return [
166
+ RemoteDirectoryResponse(path=entry_path, name=entry_path.rsplit("/", 1)[-1])
167
+ for entry_path, entry_type in entries
168
+ if entry_type == "directory"
169
+ ]
170
+
171
+ @app.post(
172
+ f"{prefix}/projects",
173
+ response_model=ProjectResponse,
174
+ status_code=status.HTTP_201_CREATED,
175
+ )
176
+ async def create_project(payload: ProjectCreate) -> ProjectResponse:
177
+ try:
178
+ project = payload.model_dump(exclude_none=True)
179
+ project["fecha_creacion"] = datetime.now(timezone.utc).isoformat()
180
+ created = await asyncio.to_thread(projects.create, project)
181
+ return _project_response(created)
182
+ except ValueError as error:
183
+ raise HTTPException(status_code=422, detail=str(error)) from None
184
+ except Exception:
185
+ raise HTTPException(
186
+ status_code=500, detail="Could not create project"
187
+ ) from None
188
+
189
+ @app.get(f"{prefix}/projects/{{project_id}}", response_model=ProjectResponse)
190
+ async def get_project(project_id: UUID4Path) -> ProjectResponse:
191
+ project = await _get_project(projects, project_id)
192
+ return _project_response(project)
193
+
194
+ @app.patch(f"{prefix}/projects/{{project_id}}", response_model=ProjectResponse)
195
+ async def patch_project(
196
+ project_id: UUID4Path, payload: ProjectPatch
197
+ ) -> ProjectResponse:
198
+ canonical_id = _uuid4_or_422(project_id)
199
+ changes = payload.model_dump(exclude_unset=True, exclude_none=False)
200
+ if not changes:
201
+ raise HTTPException(
202
+ status_code=422, detail="Patch body must contain at least one field"
203
+ )
204
+ try:
205
+ updated = await asyncio.to_thread(projects.update, canonical_id, changes)
206
+ except ValueError as error:
207
+ raise HTTPException(status_code=422, detail=str(error)) from None
208
+ except Exception:
209
+ raise HTTPException(
210
+ status_code=500, detail="Could not update project"
211
+ ) from None
212
+ if updated is None:
213
+ raise HTTPException(status_code=404, detail="Project not found")
214
+ return _project_response(updated)
215
+
216
+ @app.delete(
217
+ f"{prefix}/projects/{{project_id}}", status_code=status.HTTP_204_NO_CONTENT
218
+ )
219
+ async def delete_project(project_id: UUID4Path) -> Response:
220
+ canonical_id = _uuid4_or_422(project_id)
221
+ try:
222
+ deleted = await asyncio.to_thread(projects.delete, canonical_id)
223
+ except Exception:
224
+ raise HTTPException(
225
+ status_code=500, detail="Could not delete project"
226
+ ) from None
227
+ if not deleted:
228
+ raise HTTPException(status_code=404, detail="Project not found")
229
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
230
+
231
+ @app.get(
232
+ f"{prefix}/projects/{{project_id}}/versions",
233
+ response_model=list[VersionResponse],
234
+ )
235
+ async def list_versions(project_id: UUID4Path) -> list[VersionResponse]:
236
+ project = await _get_project(projects, project_id)
237
+ try:
238
+ return await asyncio.to_thread(
239
+ _remote_versions, storage, project, runtime_settings.CALC_JOB_DIR
240
+ )
241
+ except ValueError as error:
242
+ raise HTTPException(status_code=422, detail=str(error)) from None
243
+ except Exception:
244
+ raise HTTPException(
245
+ status_code=502, detail="Could not list remote project versions"
246
+ ) from None
247
+
248
+ @app.post(
249
+ f"{prefix}/projects/{{project_id}}/init",
250
+ response_model=JobAccepted,
251
+ status_code=status.HTTP_202_ACCEPTED,
252
+ )
253
+ async def initialize(
254
+ project_id: UUID4Path, payload: InitRequest | None = None
255
+ ) -> JobAccepted:
256
+ return await _launch(
257
+ projects,
258
+ jobs,
259
+ remote,
260
+ job_worker,
261
+ project_id,
262
+ "init",
263
+ prefix=prefix,
264
+ options=(payload or InitRequest()).model_dump(),
265
+ )
266
+
267
+ @app.post(
268
+ f"{prefix}/projects/{{project_id}}/calculate",
269
+ response_model=JobAccepted,
270
+ status_code=status.HTTP_202_ACCEPTED,
271
+ )
272
+ async def calculate(
273
+ project_id: UUID4Path, _: EmptyActionRequest | None = None
274
+ ) -> JobAccepted:
275
+ return await _launch(
276
+ projects, jobs, remote, job_worker, project_id, "calculate", prefix=prefix
277
+ )
278
+
279
+ @app.post(
280
+ f"{prefix}/projects/{{project_id}}/versions/{{version}}/du",
281
+ response_model=JobAccepted,
282
+ status_code=status.HTTP_202_ACCEPTED,
283
+ )
284
+ async def du(
285
+ project_id: UUID4Path,
286
+ version: NumericVersionPath,
287
+ payload: DuRequest | None = None,
288
+ ) -> JobAccepted:
289
+ return await _launch(
290
+ projects,
291
+ jobs,
292
+ remote,
293
+ job_worker,
294
+ project_id,
295
+ "du",
296
+ prefix=prefix,
297
+ version=_version_or_422(version),
298
+ options=(payload or DuRequest()).model_dump(exclude_none=True),
299
+ )
300
+
301
+ @app.post(
302
+ f"{prefix}/projects/{{project_id}}/versions/{{version}}/print-pdf",
303
+ response_model=JobAccepted,
304
+ status_code=status.HTTP_202_ACCEPTED,
305
+ )
306
+ async def print_pdf(
307
+ project_id: UUID4Path,
308
+ version: NumericVersionPath,
309
+ _: EmptyActionRequest | None = None,
310
+ ) -> JobAccepted:
311
+ return await _launch(
312
+ projects,
313
+ jobs,
314
+ remote,
315
+ job_worker,
316
+ project_id,
317
+ "print-pdf",
318
+ prefix=prefix,
319
+ version=_version_or_422(version),
320
+ )
321
+
322
+ @app.post(
323
+ f"{prefix}/projects/{{project_id}}/versions/{{version}}/finalize",
324
+ response_model=JobAccepted,
325
+ status_code=status.HTTP_202_ACCEPTED,
326
+ )
327
+ async def finalize(
328
+ project_id: UUID4Path,
329
+ version: NumericVersionPath,
330
+ _: EmptyActionRequest | None = None,
331
+ ) -> JobAccepted:
332
+ return await _launch(
333
+ projects,
334
+ jobs,
335
+ remote,
336
+ job_worker,
337
+ project_id,
338
+ "finalize",
339
+ prefix=prefix,
340
+ version=_version_or_422(version),
341
+ )
342
+
343
+ @app.get(f"{prefix}/jobs/{{job_id}}", response_model=JobResponse)
344
+ async def get_job(job_id: UUID4Path) -> JobResponse:
345
+ manifest = await _get_job(jobs, job_id)
346
+ directory = await asyncio.to_thread(jobs.job_directory, manifest.id)
347
+ return await asyncio.to_thread(
348
+ _job_response, manifest, directory, runtime_settings
349
+ )
350
+
351
+ @app.get(f"{prefix}/jobs/{{job_id}}/logs", response_model=JobLogsResponse)
352
+ async def job_logs(job_id: UUID4Path) -> JobLogsResponse:
353
+ manifest = await _get_job(jobs, job_id)
354
+ directory = await asyncio.to_thread(jobs.job_directory, manifest.id)
355
+ return await asyncio.to_thread(_job_logs_response, directory, runtime_settings)
356
+
357
+ @app.get(
358
+ f"{prefix}/jobs/{{job_id}}/download",
359
+ response_class=FileResponse,
360
+ responses={200: {"content": {"application/zip": {}}}},
361
+ )
362
+ async def download(job_id: UUID4Path) -> FileResponse:
363
+ manifest = await _get_job(jobs, job_id)
364
+ artifact = await asyncio.to_thread(jobs.job_directory, manifest.id)
365
+ artifact = artifact / "result.zip"
366
+ if not manifest.artifact_ready:
367
+ raise HTTPException(status_code=409, detail="Job artifact is not ready")
368
+ if not await asyncio.to_thread(_is_regular_file, artifact):
369
+ raise HTTPException(status_code=409, detail="Job artifact is not ready")
370
+ return FileResponse(
371
+ artifact, filename=f"calc-{manifest.id}.zip", media_type="application/zip"
372
+ )
373
+
374
+ return app
375
+
376
+
377
+ async def _launch(
378
+ projects: ProjectStore,
379
+ jobs: JobStore,
380
+ remote: RemoteOperations,
381
+ worker: JobWorker,
382
+ project_id: str,
383
+ operation: str,
384
+ *,
385
+ prefix: str,
386
+ version: str | None = None,
387
+ options: Mapping[str, Any] | None = None,
388
+ ) -> JobAccepted:
389
+ if operation not in _ACTIONS:
390
+ raise HTTPException(status_code=500, detail="Unsupported action")
391
+ project = await _get_project(projects, project_id)
392
+ try:
393
+ remote_path = normalize_webdav_path(str(project["path"]))
394
+ manifest = await asyncio.to_thread(
395
+ jobs.create,
396
+ operation=operation,
397
+ project_id=str(project["id"]),
398
+ remote_path=remote_path,
399
+ requested_version=version,
400
+ )
401
+ except ValueError as error:
402
+ raise HTTPException(status_code=422, detail=str(error)) from None
403
+ except Exception:
404
+ raise HTTPException(status_code=500, detail="Could not create job") from None
405
+ try:
406
+ remote.register(manifest.id, project, options=options)
407
+ await worker.enqueue(manifest)
408
+ except Exception:
409
+ try:
410
+ await asyncio.to_thread(
411
+ jobs.transition, manifest.id, JobStatus.FAILED, error="enqueue_failed"
412
+ )
413
+ except Exception:
414
+ pass
415
+ raise HTTPException(
416
+ status_code=503, detail="Job worker is unavailable"
417
+ ) from None
418
+ return JobAccepted(
419
+ job_id=uuid.UUID(manifest.id),
420
+ status_url=f"{prefix}/jobs/{manifest.id}",
421
+ download_url=f"{prefix}/jobs/{manifest.id}/download",
422
+ )
423
+
424
+
425
+ async def _get_project(store: ProjectStore, project_id: str) -> dict[str, Any]:
426
+ canonical_id = _uuid4_or_422(project_id)
427
+ try:
428
+ project = await asyncio.to_thread(store.get, canonical_id)
429
+ except Exception:
430
+ raise HTTPException(
431
+ status_code=500, detail="Project configuration is unavailable"
432
+ ) from None
433
+ if project is None:
434
+ raise HTTPException(status_code=404, detail="Project not found")
435
+ return project
436
+
437
+
438
+ async def _get_job(store: JobStore, job_id: str) -> JobManifest:
439
+ canonical_id = _uuid4_or_422(job_id)
440
+ try:
441
+ manifest = await asyncio.to_thread(store.get, canonical_id)
442
+ except Exception:
443
+ raise HTTPException(
444
+ status_code=500, detail="Job state is unavailable"
445
+ ) from None
446
+ if manifest is None:
447
+ raise HTTPException(status_code=404, detail="Job not found")
448
+ return manifest
449
+
450
+
451
+ def _uuid4_or_422(value: str) -> str:
452
+ try:
453
+ parsed = uuid.UUID(value)
454
+ except ValueError:
455
+ raise HTTPException(
456
+ status_code=422, detail="Identifier must be a UUID4"
457
+ ) from None
458
+ if parsed.version != 4 or str(parsed) != value:
459
+ raise HTTPException(
460
+ status_code=422, detail="Identifier must be a canonical UUID4"
461
+ )
462
+ return value
463
+
464
+
465
+ def _version_or_422(value: str) -> str:
466
+ if not value.isascii() or not value.isdecimal():
467
+ raise HTTPException(status_code=422, detail="Version must be numeric")
468
+ return value
469
+
470
+
471
+ def _project_response(project: Mapping[str, Any]) -> ProjectResponse:
472
+ try:
473
+ return ProjectResponse.model_validate(project)
474
+ except Exception:
475
+ raise HTTPException(
476
+ status_code=500, detail="Stored project has an invalid schema"
477
+ ) from None
478
+
479
+
480
+ def _job_response(
481
+ manifest: JobManifest, directory: Path, settings: Settings
482
+ ) -> JobResponse:
483
+ document = manifest.to_dict()
484
+ document.pop("revision", None)
485
+ document.pop("calculation_started", None)
486
+ if document["error"] is not None:
487
+ document["error"] = _bounded_manifest_error(
488
+ _safe_log(document["error"], settings)
489
+ )
490
+ return JobResponse(
491
+ **document,
492
+ stdout_tail=_safe_log(_read_log_tail(directory / "stdout.log"), settings),
493
+ stderr_tail=_safe_log(_read_log_tail(directory / "stderr.log"), settings),
494
+ )
495
+
496
+
497
+ def _job_logs_response(directory: Path, settings: Settings) -> JobLogsResponse:
498
+ return JobLogsResponse(
499
+ stdout=_safe_log(_read_log(directory / "stdout.log"), settings),
500
+ stderr=_safe_log(_read_log(directory / "stderr.log"), settings),
501
+ )
502
+
503
+
504
+ def _is_regular_file(path: Path) -> bool:
505
+ try:
506
+ return stat.S_ISREG(path.lstat().st_mode)
507
+ except FileNotFoundError:
508
+ return False
509
+
510
+
511
+ def _remote_versions(
512
+ storage: Any, project: Mapping[str, Any], job_root: Path
513
+ ) -> list[VersionResponse]:
514
+ remote_project = normalize_webdav_path(str(project["path"]))
515
+ remote_results = f"{remote_project}/RESULTADOS"
516
+ if not storage.exists(remote_results):
517
+ return []
518
+
519
+ job_root.mkdir(parents=True, exist_ok=True)
520
+ with tempfile.TemporaryDirectory(prefix="versions-", dir=job_root) as temporary:
521
+ temporary_root = Path(temporary)
522
+ response: list[VersionResponse] = []
523
+ numeric_versions = sorted(
524
+ (
525
+ (remote_version, remote_version.rsplit("/", 1)[-1])
526
+ for remote_version, entry_type in storage.list_directory(remote_results)
527
+ if entry_type == "directory"
528
+ and (version := remote_version.rsplit("/", 1)[-1]).isascii()
529
+ and version.isdecimal()
530
+ ),
531
+ key=lambda item: (
532
+ len(item[1].lstrip("0") or "0"),
533
+ item[1].lstrip("0") or "0",
534
+ ),
535
+ )
536
+ for remote_version, version in numeric_versions:
537
+ state: Any = {}
538
+ remote_version_json = f"{remote_version}/version.json"
539
+ entries = storage.list_directory(remote_version)
540
+ if (remote_version_json, "file") in entries:
541
+ local_version_json = temporary_root / "version.json"
542
+ storage.download_file(remote_version_json, local_version_json)
543
+ try:
544
+ state = json.loads(local_version_json.read_text(encoding="utf-8"))
545
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
546
+ state = {}
547
+
548
+ raw_estado = state.get("estado") if isinstance(state, dict) else None
549
+ estado: Literal["calculando", "finalizada"] | None
550
+ if raw_estado == "calculando":
551
+ estado = "calculando"
552
+ elif raw_estado == "finalizada":
553
+ estado = "finalizada"
554
+ else:
555
+ estado = None
556
+ response.append(VersionResponse(version=version, estado=estado))
557
+ return response
558
+
559
+
560
+ def _directory_writable(directory: Path) -> bool:
561
+ try:
562
+ directory.mkdir(parents=True, exist_ok=True)
563
+ with tempfile.NamedTemporaryFile(
564
+ dir=directory, prefix=".health-", delete=True
565
+ ) as output:
566
+ output.write(b"ok")
567
+ output.flush()
568
+ os.fsync(output.fileno())
569
+ return True
570
+ except OSError:
571
+ return False
572
+
573
+
574
+ def _read_log(path: Path) -> str:
575
+ try:
576
+ return path.read_text(encoding="utf-8", errors="replace")
577
+ except FileNotFoundError:
578
+ return ""
579
+ except OSError:
580
+ return ""
581
+
582
+
583
+ def _read_log_tail(path: Path) -> str:
584
+ try:
585
+ with path.open("rb") as source:
586
+ source.seek(0, os.SEEK_END)
587
+ size = source.tell()
588
+ source.seek(max(0, size - _LOG_TAIL_BYTES))
589
+ return source.read().decode("utf-8", errors="replace")
590
+ except FileNotFoundError:
591
+ return ""
592
+ except OSError:
593
+ return ""
594
+
595
+
596
+ def _safe_log(value: str, settings: Settings) -> str:
597
+ return redact_text(value, settings)
598
+
599
+
600
+ def _bounded_manifest_error(value: str) -> str:
601
+ encoded = value.encode("utf-8")
602
+ if len(encoded) <= _MANIFEST_ERROR_BYTES:
603
+ return value
604
+ return encoded[:_MANIFEST_ERROR_BYTES].decode("utf-8", errors="ignore") + "…"
605
+
606
+
607
+ app = create_app()