wolfzbr 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.
- wolfz/__init__.py +22 -0
- wolfz/api/__init__.py +1 -0
- wolfz/api/app.py +58 -0
- wolfz/api/modelos.py +148 -0
- wolfz/api/problems.py +102 -0
- wolfz/api/projetos.py +97 -0
- wolfz/api/rotas.py +398 -0
- wolfz/api/security.py +101 -0
- wolfz/api/server.py +72 -0
- wolfz/br/__init__.py +6 -0
- wolfz/br/booleanos.py +25 -0
- wolfz/br/constants.py +45 -0
- wolfz/br/datas.py +137 -0
- wolfz/br/documentos.py +215 -0
- wolfz/br/geo.py +48 -0
- wolfz/br/numeros.py +229 -0
- wolfz/cli.py +336 -0
- wolfz/core/__init__.py +1 -0
- wolfz/core/detect/__init__.py +6 -0
- wolfz/core/detect/metrics.py +144 -0
- wolfz/core/detect/structure.py +297 -0
- wolfz/core/engine/__init__.py +132 -0
- wolfz/core/engine/cache.py +195 -0
- wolfz/core/engine/errors.py +58 -0
- wolfz/core/engine/preview.py +160 -0
- wolfz/core/ingest/__init__.py +176 -0
- wolfz/core/ingest/cells.py +153 -0
- wolfz/core/ingest/csv.py +196 -0
- wolfz/core/ingest/errors.py +93 -0
- wolfz/core/ingest/excel.py +256 -0
- wolfz/core/ingest/formats.py +145 -0
- wolfz/core/ingest/limits.py +98 -0
- wolfz/core/ingest/structure.py +208 -0
- wolfz/core/naming.py +45 -0
- wolfz/core/profile/__init__.py +20 -0
- wolfz/core/profile/column.py +137 -0
- wolfz/core/profile/infer.py +51 -0
- wolfz/core/profile/inspect.py +68 -0
- wolfz/core/profile/quick.py +45 -0
- wolfz/core/recipe/__init__.py +32 -0
- wolfz/core/recipe/io.py +173 -0
- wolfz/core/recipe/migrate.py +62 -0
- wolfz/core/recipe/models.py +205 -0
- wolfz/core/suggest/__init__.py +1 -0
- wolfz/core/text.py +115 -0
- wolfz/core/workspace.py +134 -0
- wolfz/export/__init__.py +1 -0
- wolfz/export/dados.py +285 -0
- wolfz/export/m.py +67 -0
- wolfz/export/python.py +144 -0
- wolfz/ops/__init__.py +55 -0
- wolfz/ops/br.py +620 -0
- wolfz/ops/colunas.py +354 -0
- wolfz/ops/estrutura.py +517 -0
- wolfz/ops/linhas.py +410 -0
- wolfz/ops/m_br.py +369 -0
- wolfz/ops/tabela.py +189 -0
- wolfz/ops/texto.py +251 -0
- wolfz/static/.gitkeep +0 -0
- wolfz/static/assets/data-grid-overlay-editor-DbA0JPlf.js +1 -0
- wolfz/static/assets/index-DWorz2kP.css +1 -0
- wolfz/static/assets/index-Dx3RP7p-.js +182 -0
- wolfz/static/assets/number-overlay-editor-DDUrQ7zj.js +9 -0
- wolfz/static/index.html +15 -0
- wolfzbr-0.4.0.dist-info/METADATA +169 -0
- wolfzbr-0.4.0.dist-info/RECORD +69 -0
- wolfzbr-0.4.0.dist-info/WHEEL +4 -0
- wolfzbr-0.4.0.dist-info/entry_points.txt +2 -0
- wolfzbr-0.4.0.dist-info/licenses/LICENSE +21 -0
wolfz/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""WolfzBR: limpeza de planilhas brasileiras com receitas reproduzíveis.
|
|
2
|
+
|
|
3
|
+
API pública mínima para uso em notebook e scripts::
|
|
4
|
+
|
|
5
|
+
import wolfz
|
|
6
|
+
|
|
7
|
+
receita = wolfz.load_recipe("vendas.wolfz.json")
|
|
8
|
+
df = wolfz.run(receita, "vendas_jan.csv")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
|
|
13
|
+
from wolfz.core.engine import StepError
|
|
14
|
+
from wolfz.core.engine import run_file as run
|
|
15
|
+
from wolfz.core.recipe import RecipeError, load_recipe
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
__version__ = version("wolfzbr")
|
|
19
|
+
except PackageNotFoundError: # rodando do código-fonte sem instalar
|
|
20
|
+
__version__ = "0.0.0+desconhecida"
|
|
21
|
+
|
|
22
|
+
__all__ = ["RecipeError", "StepError", "__version__", "load_recipe", "run"]
|
wolfz/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API FastAPI local: transporte, token de sessão e servir a SPA. Sem regra de negócio."""
|
wolfz/api/app.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Aplicação FastAPI: rotas em ``/api``, a SPA compilada na raiz."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from fastapi.responses import HTMLResponse
|
|
10
|
+
from fastapi.staticfiles import StaticFiles
|
|
11
|
+
|
|
12
|
+
import wolfz.ops # noqa: F401 (registra o catálogo de operações)
|
|
13
|
+
from wolfz import __version__
|
|
14
|
+
from wolfz.api import problems, security
|
|
15
|
+
from wolfz.api.projetos import Projetos
|
|
16
|
+
from wolfz.api.rotas import router
|
|
17
|
+
from wolfz.api.security import Settings
|
|
18
|
+
|
|
19
|
+
_SEM_INTERFACE = """<!doctype html>
|
|
20
|
+
<html lang="pt-BR"><head><meta charset="utf-8"><title>WolfzBR</title></head>
|
|
21
|
+
<body style="font-family: system-ui, sans-serif; max-width: 40rem; margin: 4rem auto">
|
|
22
|
+
<h1>WolfzBR</h1>
|
|
23
|
+
<p>A API está no ar, mas a interface ainda não foi compilada neste pacote.</p>
|
|
24
|
+
</body></html>
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_app(settings: Settings) -> FastAPI:
|
|
29
|
+
projetos = Projetos(settings.workspace, settings.port)
|
|
30
|
+
|
|
31
|
+
@asynccontextmanager
|
|
32
|
+
async def ciclo(app: FastAPI) -> AsyncIterator[None]:
|
|
33
|
+
yield
|
|
34
|
+
projetos.fechar_todos() # libera os locks ao encerrar
|
|
35
|
+
|
|
36
|
+
app = FastAPI(
|
|
37
|
+
title="WolfzBR Local API",
|
|
38
|
+
version=__version__,
|
|
39
|
+
lifespan=ciclo,
|
|
40
|
+
docs_url="/api/docs" if settings.dev else None,
|
|
41
|
+
redoc_url=None,
|
|
42
|
+
openapi_url="/api/openapi.json" if settings.dev else None,
|
|
43
|
+
)
|
|
44
|
+
app.state.settings = settings
|
|
45
|
+
app.state.projetos = projetos
|
|
46
|
+
problems.install(app)
|
|
47
|
+
security.install(app, settings)
|
|
48
|
+
app.include_router(router(settings, projetos))
|
|
49
|
+
|
|
50
|
+
if (settings.static_dir / "index.html").is_file():
|
|
51
|
+
app.mount("/", StaticFiles(directory=settings.static_dir, html=True), name="spa")
|
|
52
|
+
else:
|
|
53
|
+
|
|
54
|
+
@app.get("/", include_in_schema=False)
|
|
55
|
+
def sem_interface() -> HTMLResponse:
|
|
56
|
+
return HTMLResponse(_SEM_INTERFACE)
|
|
57
|
+
|
|
58
|
+
return app
|
wolfz/api/modelos.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Modelos das respostas da API. Existem para o OpenAPI: o cliente
|
|
2
|
+
TypeScript da interface é gerado a partir dele (F1.H1). Os handlers
|
|
3
|
+
continuam devolvendo dicionários; o FastAPI valida contra estes modelos.
|
|
4
|
+
|
|
5
|
+
A receita fica como objeto livre (``dict``): o formato dela é o JSON
|
|
6
|
+
Schema da receita (``wolfz schema``), e os parâmetros de cada passo vêm de
|
|
7
|
+
``GET /ops``."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Resposta(BaseModel):
|
|
17
|
+
# Campo a mais no dicionário passa adiante: a resposta não perde
|
|
18
|
+
# informação se o núcleo ganhar um campo antes do modelo.
|
|
19
|
+
model_config = ConfigDict(extra="allow")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Aba(_Resposta):
|
|
23
|
+
index: int
|
|
24
|
+
name: str
|
|
25
|
+
visibility: str
|
|
26
|
+
hidden: bool
|
|
27
|
+
rows: int | None = None
|
|
28
|
+
columns: int | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Deteccao(_Resposta):
|
|
32
|
+
"""Estrutura detectada, em linhas e letras do Excel."""
|
|
33
|
+
|
|
34
|
+
cabecalho: list[int] | None = None
|
|
35
|
+
dados: list[int]
|
|
36
|
+
colunas: list[str]
|
|
37
|
+
totais: list[int] = []
|
|
38
|
+
fill_down: list[str] = []
|
|
39
|
+
titulo: list[str] = []
|
|
40
|
+
notas: list[str] = []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Fonte(_Resposta):
|
|
44
|
+
name: str
|
|
45
|
+
format: str
|
|
46
|
+
size: int
|
|
47
|
+
sheet: str | None = None
|
|
48
|
+
path: str | None = None
|
|
49
|
+
uploaded: bool = False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Raw(_Resposta):
|
|
53
|
+
rows: int
|
|
54
|
+
columns: list[str]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Projeto(_Resposta):
|
|
58
|
+
id: str
|
|
59
|
+
name: str
|
|
60
|
+
created_at: str
|
|
61
|
+
source: Fonte
|
|
62
|
+
sheets: list[Aba] = []
|
|
63
|
+
raw: Raw
|
|
64
|
+
recipe: dict[str, Any]
|
|
65
|
+
etag: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ProjetoCriado(Projeto):
|
|
69
|
+
reused_raw: bool
|
|
70
|
+
detection: Deteccao | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ProjetoResumo(_Resposta):
|
|
74
|
+
id: str
|
|
75
|
+
name: str | None = None
|
|
76
|
+
created_at: str | None = None
|
|
77
|
+
format: str | None = None
|
|
78
|
+
sheet: str | None = None
|
|
79
|
+
rows: int | None = None
|
|
80
|
+
open_here: bool
|
|
81
|
+
open_elsewhere: bool
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Estrutura(_Resposta):
|
|
85
|
+
structure: dict[str, Any]
|
|
86
|
+
detection: Deteccao | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class FormaValor(_Resposta):
|
|
90
|
+
shape: str
|
|
91
|
+
count: int
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PerfilColuna(_Resposta):
|
|
95
|
+
name: str
|
|
96
|
+
inferred_type: str
|
|
97
|
+
type_rate: float
|
|
98
|
+
rows: int
|
|
99
|
+
nulls: int
|
|
100
|
+
null_markers: int
|
|
101
|
+
distinct: int
|
|
102
|
+
top_shapes: list[FormaValor]
|
|
103
|
+
examples: list[str]
|
|
104
|
+
minimum: float | str | None = None
|
|
105
|
+
maximum: float | str | None = None
|
|
106
|
+
null_pct: float
|
|
107
|
+
null_marker_pct: float
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Perfil(_Resposta):
|
|
111
|
+
total_rows: int
|
|
112
|
+
sampled_rows: int
|
|
113
|
+
is_sample: bool
|
|
114
|
+
columns: list[PerfilColuna]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ColunaPreview(_Resposta):
|
|
118
|
+
name: str
|
|
119
|
+
dtype: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Pagina(_Resposta):
|
|
123
|
+
columns: list[ColunaPreview]
|
|
124
|
+
rows: list[list[Any]]
|
|
125
|
+
total_rows: int
|
|
126
|
+
offset: int
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ReceitaSalva(_Resposta):
|
|
130
|
+
etag: str
|
|
131
|
+
recipe: dict[str, Any]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Exportacao(_Resposta):
|
|
135
|
+
files: list[str]
|
|
136
|
+
rows: int | None = None
|
|
137
|
+
# Células de texto que começam com =, +, -, @… (None: só código exportado).
|
|
138
|
+
formula_cells: int | None = None
|
|
139
|
+
neutralized: bool = False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Operacao(_Resposta):
|
|
143
|
+
op: str
|
|
144
|
+
doc: str
|
|
145
|
+
# "schema" é nome de método antigo do BaseModel; o campo usa alias.
|
|
146
|
+
schema_: dict[str, Any] = Field(alias="schema")
|
|
147
|
+
full_checkpoint: bool
|
|
148
|
+
examples: list[dict[str, Any]]
|
wolfz/api/problems.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Erros no formato Problem Details (RFC 9457, §5.4 do design).
|
|
2
|
+
|
|
3
|
+
Toda resposta de erro da API tem o mesmo corpo: ``type``, ``title``,
|
|
4
|
+
``status``, ``detail`` e ``instance``, mais ``step_id`` e
|
|
5
|
+
``available_columns`` quando o erro é de um passo da receita. É o mesmo
|
|
6
|
+
texto que a CLI mostra, porque vem das mesmas exceções do núcleo.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from fastapi import FastAPI, Request
|
|
15
|
+
from fastapi.exceptions import RequestValidationError
|
|
16
|
+
from fastapi.responses import JSONResponse
|
|
17
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
18
|
+
|
|
19
|
+
from wolfz.core.engine import StepError
|
|
20
|
+
from wolfz.core.ingest import IngestError
|
|
21
|
+
from wolfz.core.recipe import RecipeError
|
|
22
|
+
|
|
23
|
+
PROBLEM_JSON = "application/problem+json"
|
|
24
|
+
log = logging.getLogger("wolfz.api")
|
|
25
|
+
|
|
26
|
+
_TITULOS = {
|
|
27
|
+
400: "Requisição inválida",
|
|
28
|
+
401: "Token de sessão ausente ou inválido",
|
|
29
|
+
403: "Acesso negado",
|
|
30
|
+
404: "Não encontrado",
|
|
31
|
+
405: "Método não permitido",
|
|
32
|
+
409: "Conflito",
|
|
33
|
+
413: "Arquivo grande demais",
|
|
34
|
+
415: "Formato não suportado",
|
|
35
|
+
422: "Dados inválidos",
|
|
36
|
+
423: "Projeto aberto em outra instância",
|
|
37
|
+
428: "Cabeçalho If-Match obrigatório",
|
|
38
|
+
500: "Erro interno",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Problem(Exception):
|
|
43
|
+
"""Erro da API com status e texto para o usuário."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, status: int, detail: str, title: str | None = None, **extra: Any):
|
|
46
|
+
self.status = status
|
|
47
|
+
self.detail = detail
|
|
48
|
+
self.title = title or _TITULOS.get(status, "Erro")
|
|
49
|
+
self.extra = extra
|
|
50
|
+
super().__init__(detail)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def problem_response(request: Request, status: int, title: str, detail: str, **extra: Any) -> JSONResponse:
|
|
54
|
+
corpo = {
|
|
55
|
+
"type": "about:blank",
|
|
56
|
+
"title": title,
|
|
57
|
+
"status": status,
|
|
58
|
+
"detail": detail,
|
|
59
|
+
"instance": request.url.path,
|
|
60
|
+
**extra,
|
|
61
|
+
}
|
|
62
|
+
return JSONResponse(corpo, status_code=status, media_type=PROBLEM_JSON)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def install(app: FastAPI) -> None:
|
|
66
|
+
"""Registra os tradutores de exceção da aplicação."""
|
|
67
|
+
|
|
68
|
+
@app.exception_handler(Problem)
|
|
69
|
+
async def _problem(request: Request, exc: Problem) -> JSONResponse:
|
|
70
|
+
return problem_response(request, exc.status, exc.title, exc.detail, **exc.extra)
|
|
71
|
+
|
|
72
|
+
@app.exception_handler(StepError)
|
|
73
|
+
async def _step(request: Request, exc: StepError) -> JSONResponse:
|
|
74
|
+
corpo = exc.to_problem(status=422, instance=request.url.path)
|
|
75
|
+
return JSONResponse(corpo, status_code=422, media_type=PROBLEM_JSON)
|
|
76
|
+
|
|
77
|
+
@app.exception_handler(IngestError)
|
|
78
|
+
async def _ingest(request: Request, exc: IngestError) -> JSONResponse:
|
|
79
|
+
corpo = exc.to_problem(instance=request.url.path)
|
|
80
|
+
return JSONResponse(corpo, status_code=exc.status, media_type=PROBLEM_JSON)
|
|
81
|
+
|
|
82
|
+
@app.exception_handler(RecipeError)
|
|
83
|
+
async def _recipe(request: Request, exc: RecipeError) -> JSONResponse:
|
|
84
|
+
detalhe = "; ".join(exc.errors) if exc.errors else str(exc)
|
|
85
|
+
return problem_response(request, 422, "Receita inválida", detalhe, errors=exc.errors)
|
|
86
|
+
|
|
87
|
+
@app.exception_handler(RequestValidationError)
|
|
88
|
+
async def _validation(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
89
|
+
erros = [f"{'.'.join(str(p) for p in e['loc'])}: {e['msg']}" for e in exc.errors()]
|
|
90
|
+
return problem_response(request, 422, _TITULOS[422], "; ".join(erros), errors=erros)
|
|
91
|
+
|
|
92
|
+
@app.exception_handler(StarletteHTTPException)
|
|
93
|
+
async def _http(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
|
94
|
+
titulo = _TITULOS.get(exc.status_code, "Erro")
|
|
95
|
+
return problem_response(request, exc.status_code, titulo, str(exc.detail))
|
|
96
|
+
|
|
97
|
+
@app.exception_handler(Exception)
|
|
98
|
+
async def _inesperado(request: Request, exc: Exception) -> JSONResponse:
|
|
99
|
+
# Sem detalhe da exceção na resposta nem no log: a mensagem pode
|
|
100
|
+
# conter valor de célula (§12.1).
|
|
101
|
+
log.error("erro não tratado em %s: %s", request.url.path, type(exc).__name__)
|
|
102
|
+
return problem_response(request, 500, _TITULOS[500], "Erro inesperado. Veja o log do WolfzBR.")
|
wolfz/api/projetos.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Projetos abertos nesta instância: lock por projeto e serialização do preview.
|
|
2
|
+
|
|
3
|
+
Lock (§9 do design, F0.A8): lock de arquivo do sistema operacional em
|
|
4
|
+
``<projeto>/.lock`` (pacote ``filelock``), adquirido ao criar ou abrir o
|
|
5
|
+
projeto e mantido até fechar o projeto ou encerrar o processo. Se o processo
|
|
6
|
+
morre, o SO libera sozinho. Ao lado, ``.lock.json`` com pid, porta e hora,
|
|
7
|
+
só para a mensagem de erro ("aberto em outra janela, em http://…").
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from filelock import FileLock, Timeout
|
|
19
|
+
|
|
20
|
+
from wolfz.api.problems import Problem
|
|
21
|
+
from wolfz.core.engine.cache import projeto_travado
|
|
22
|
+
from wolfz.core.workspace import PROJECT_FILE, Workspace
|
|
23
|
+
|
|
24
|
+
LOCK_META = ".lock.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Projetos:
|
|
28
|
+
def __init__(self, workspace: Workspace, port: int):
|
|
29
|
+
self.workspace = workspace
|
|
30
|
+
self.port = port
|
|
31
|
+
self._locks: dict[str, FileLock] = {}
|
|
32
|
+
self._previews: dict[str, threading.Lock] = {}
|
|
33
|
+
self._mutex = threading.Lock()
|
|
34
|
+
|
|
35
|
+
def pasta(self, project_id: str) -> Path:
|
|
36
|
+
"""Pasta do projeto; 404 se o id é inválido ou não existe."""
|
|
37
|
+
try:
|
|
38
|
+
pasta = self.workspace.project_dir(project_id)
|
|
39
|
+
except ValueError as exc:
|
|
40
|
+
raise Problem(404, f"Projeto {project_id!r} não existe.") from exc
|
|
41
|
+
if not (pasta / PROJECT_FILE).is_file():
|
|
42
|
+
raise Problem(404, f"Projeto {project_id!r} não existe.")
|
|
43
|
+
return pasta
|
|
44
|
+
|
|
45
|
+
def abrir(self, project_id: str) -> Path:
|
|
46
|
+
"""Abre o projeto nesta instância (idempotente); 423 se ele está aberto
|
|
47
|
+
em outra."""
|
|
48
|
+
pasta = self.pasta(project_id)
|
|
49
|
+
with self._mutex:
|
|
50
|
+
if project_id in self._locks:
|
|
51
|
+
return pasta
|
|
52
|
+
lock = FileLock(pasta / ".lock")
|
|
53
|
+
try:
|
|
54
|
+
lock.acquire(timeout=0)
|
|
55
|
+
except Timeout as exc:
|
|
56
|
+
raise Problem(
|
|
57
|
+
423,
|
|
58
|
+
"Este projeto já está aberto em outra janela do WolfzBR" + self._onde(pasta) + ".",
|
|
59
|
+
) from exc
|
|
60
|
+
meta = {"pid": os.getpid(), "port": self.port, "opened_at": datetime.now(UTC).isoformat()}
|
|
61
|
+
(pasta / LOCK_META).write_text(json.dumps(meta), encoding="utf-8")
|
|
62
|
+
self._locks[project_id] = lock
|
|
63
|
+
return pasta
|
|
64
|
+
|
|
65
|
+
def _onde(self, pasta: Path) -> str:
|
|
66
|
+
try:
|
|
67
|
+
meta = json.loads((pasta / LOCK_META).read_text(encoding="utf-8"))
|
|
68
|
+
return f", em http://127.0.0.1:{int(meta['port'])}"
|
|
69
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
def aberto_aqui(self, project_id: str) -> bool:
|
|
73
|
+
return project_id in self._locks
|
|
74
|
+
|
|
75
|
+
def aberto_em_outra(self, project_id: str, pasta: Path) -> bool:
|
|
76
|
+
return not self.aberto_aqui(project_id) and projeto_travado(pasta)
|
|
77
|
+
|
|
78
|
+
def fechar(self, project_id: str) -> None:
|
|
79
|
+
with self._mutex:
|
|
80
|
+
lock = self._locks.pop(project_id, None)
|
|
81
|
+
self._previews.pop(project_id, None)
|
|
82
|
+
if lock is not None:
|
|
83
|
+
pasta = Path(lock.lock_file).parent
|
|
84
|
+
(pasta / LOCK_META).unlink(missing_ok=True)
|
|
85
|
+
lock.release()
|
|
86
|
+
|
|
87
|
+
def fechar_todos(self) -> None:
|
|
88
|
+
for project_id in list(self._locks):
|
|
89
|
+
self.fechar(project_id)
|
|
90
|
+
|
|
91
|
+
def lock_preview(self, project_id: str) -> threading.Lock:
|
|
92
|
+
"""Um preview por projeto por vez (§11.5): o segundo espera o primeiro."""
|
|
93
|
+
with self._mutex:
|
|
94
|
+
return self._previews.setdefault(project_id, threading.Lock())
|
|
95
|
+
|
|
96
|
+
def abertos(self) -> set[Path]:
|
|
97
|
+
return {Path(lock.lock_file).parent for lock in self._locks.values()}
|