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.
Files changed (69) hide show
  1. wolfz/__init__.py +22 -0
  2. wolfz/api/__init__.py +1 -0
  3. wolfz/api/app.py +58 -0
  4. wolfz/api/modelos.py +148 -0
  5. wolfz/api/problems.py +102 -0
  6. wolfz/api/projetos.py +97 -0
  7. wolfz/api/rotas.py +398 -0
  8. wolfz/api/security.py +101 -0
  9. wolfz/api/server.py +72 -0
  10. wolfz/br/__init__.py +6 -0
  11. wolfz/br/booleanos.py +25 -0
  12. wolfz/br/constants.py +45 -0
  13. wolfz/br/datas.py +137 -0
  14. wolfz/br/documentos.py +215 -0
  15. wolfz/br/geo.py +48 -0
  16. wolfz/br/numeros.py +229 -0
  17. wolfz/cli.py +336 -0
  18. wolfz/core/__init__.py +1 -0
  19. wolfz/core/detect/__init__.py +6 -0
  20. wolfz/core/detect/metrics.py +144 -0
  21. wolfz/core/detect/structure.py +297 -0
  22. wolfz/core/engine/__init__.py +132 -0
  23. wolfz/core/engine/cache.py +195 -0
  24. wolfz/core/engine/errors.py +58 -0
  25. wolfz/core/engine/preview.py +160 -0
  26. wolfz/core/ingest/__init__.py +176 -0
  27. wolfz/core/ingest/cells.py +153 -0
  28. wolfz/core/ingest/csv.py +196 -0
  29. wolfz/core/ingest/errors.py +93 -0
  30. wolfz/core/ingest/excel.py +256 -0
  31. wolfz/core/ingest/formats.py +145 -0
  32. wolfz/core/ingest/limits.py +98 -0
  33. wolfz/core/ingest/structure.py +208 -0
  34. wolfz/core/naming.py +45 -0
  35. wolfz/core/profile/__init__.py +20 -0
  36. wolfz/core/profile/column.py +137 -0
  37. wolfz/core/profile/infer.py +51 -0
  38. wolfz/core/profile/inspect.py +68 -0
  39. wolfz/core/profile/quick.py +45 -0
  40. wolfz/core/recipe/__init__.py +32 -0
  41. wolfz/core/recipe/io.py +173 -0
  42. wolfz/core/recipe/migrate.py +62 -0
  43. wolfz/core/recipe/models.py +205 -0
  44. wolfz/core/suggest/__init__.py +1 -0
  45. wolfz/core/text.py +115 -0
  46. wolfz/core/workspace.py +134 -0
  47. wolfz/export/__init__.py +1 -0
  48. wolfz/export/dados.py +285 -0
  49. wolfz/export/m.py +67 -0
  50. wolfz/export/python.py +144 -0
  51. wolfz/ops/__init__.py +55 -0
  52. wolfz/ops/br.py +620 -0
  53. wolfz/ops/colunas.py +354 -0
  54. wolfz/ops/estrutura.py +517 -0
  55. wolfz/ops/linhas.py +410 -0
  56. wolfz/ops/m_br.py +369 -0
  57. wolfz/ops/tabela.py +189 -0
  58. wolfz/ops/texto.py +251 -0
  59. wolfz/static/.gitkeep +0 -0
  60. wolfz/static/assets/data-grid-overlay-editor-DbA0JPlf.js +1 -0
  61. wolfz/static/assets/index-DWorz2kP.css +1 -0
  62. wolfz/static/assets/index-Dx3RP7p-.js +182 -0
  63. wolfz/static/assets/number-overlay-editor-DDUrQ7zj.js +9 -0
  64. wolfz/static/index.html +15 -0
  65. wolfzbr-0.4.0.dist-info/METADATA +169 -0
  66. wolfzbr-0.4.0.dist-info/RECORD +69 -0
  67. wolfzbr-0.4.0.dist-info/WHEEL +4 -0
  68. wolfzbr-0.4.0.dist-info/entry_points.txt +2 -0
  69. 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()}