get-sinapi 3.1.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.
@@ -0,0 +1,68 @@
1
+ """Orquestra a leitura das tabelas oficiais de referência SINAPI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from openpyxl import load_workbook
9
+
10
+ from getsinapi.extractors.compositions import (
11
+ CompositionParseResult,
12
+ parse_analytic_compositions,
13
+ )
14
+ from getsinapi.extractors.price_tables import (
15
+ InputPriceParseResult,
16
+ parse_input_price_table,
17
+ )
18
+
19
+ PRICE_SHEETS = ("ISD", "ICD", "ISE")
20
+ COMPOSITION_SHEET = "Analítico"
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class ReferenceWorkbookResult:
25
+ reference: str
26
+ source_file: str
27
+ prices: tuple[InputPriceParseResult, ...]
28
+ compositions: CompositionParseResult
29
+
30
+
31
+ def parse_reference_workbook(
32
+ workbook_path: str | Path,
33
+ *,
34
+ reference: str,
35
+ ) -> ReferenceWorkbookResult:
36
+ """Extrai preços ISD/ICD/ISE e estrutura Analítico do mesmo XLSX oficial.
37
+
38
+ A função não persiste nem transforma ausência de preço em zero; entrega
39
+ resultados tipados para a próxima etapa de validação e carga.
40
+ """
41
+ path = Path(workbook_path)
42
+ workbook = load_workbook(path, read_only=True, data_only=True)
43
+ try:
44
+ available = set(workbook.sheetnames)
45
+ finally:
46
+ workbook.close()
47
+
48
+ required = {*PRICE_SHEETS, COMPOSITION_SHEET}
49
+ missing = sorted(required - available)
50
+ if missing:
51
+ raise ValueError(
52
+ "Planilha de referência incompleta; abas ausentes: "
53
+ + ", ".join(missing)
54
+ )
55
+
56
+ prices = tuple(
57
+ parse_input_price_table(path, sheet_name, reference=reference)
58
+ for sheet_name in PRICE_SHEETS
59
+ )
60
+ compositions = parse_analytic_compositions(
61
+ path, COMPOSITION_SHEET, reference=reference
62
+ )
63
+ return ReferenceWorkbookResult(
64
+ reference=reference,
65
+ source_file=path.name,
66
+ prices=prices,
67
+ compositions=compositions,
68
+ )
@@ -0,0 +1,158 @@
1
+ """Parser configurável para planilhas SINAPI catalogadas.
2
+
3
+ A detecção de colunas é conservadora: só mapeia cabeçalhos conhecidos.
4
+ Nenhuma coluna ou regime é inferido apenas pela posição da coluna.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import unicodedata
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from openpyxl import load_workbook
16
+
17
+ from getsinapi.domain import PriceObservation, Regime
18
+ from getsinapi.normalizers import classify_price
19
+
20
+ HEADER_ALIASES = {
21
+ "code": {"codigo", "cod", "codigo do insumo", "codigo da composicao"},
22
+ "description": {"descricao", "descricao do insumo", "descricao da composicao"},
23
+ "unit": {"unidade", "un"},
24
+ "price": {"preco", "preco mediano", "custo"},
25
+ "status": {"situacao", "observacao", "status", "nota"},
26
+ }
27
+ UF_PATTERN = re.compile(
28
+ r"\b(AC|AL|AP|AM|BA|CE|DF|ES|GO|MA|MT|MS|MG|PA|PB|PR|PE|PI|RJ|RN|RS|RO|RR|SC|SP|SE|TO)\b"
29
+ )
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class SheetParseResult:
34
+ sheet_name: str
35
+ regime: Regime | None
36
+ uf: str | None
37
+ header_row: int | None
38
+ records: tuple[PriceObservation, ...]
39
+ warnings: tuple[str, ...]
40
+
41
+
42
+ def normalize_header(value: Any) -> str:
43
+ if value is None:
44
+ return ""
45
+ text = unicodedata.normalize("NFKD", str(value))
46
+ text = "".join(char for char in text if not unicodedata.combining(char))
47
+ return re.sub(r"\s+", " ", re.sub(r"[^a-zA-Z0-9 ]", " ", text)).strip().lower()
48
+
49
+
50
+ def detect_regime(sheet_name: str) -> Regime | None:
51
+ normalized = normalize_header(sheet_name)
52
+ if "nao desonerado" in normalized or "nao desonerada" in normalized:
53
+ return Regime.NAO_DESONERADO
54
+ if "desonerado" in normalized or "desonerada" in normalized:
55
+ return Regime.DESONERADO
56
+ return None
57
+
58
+
59
+ def detect_uf(*values: Any) -> str | None:
60
+ text = " ".join(str(value) for value in values if value is not None).upper()
61
+ matches = UF_PATTERN.findall(text)
62
+ return matches[0] if len(set(matches)) == 1 else None
63
+
64
+
65
+ def _column_map(row: tuple[Any, ...]) -> dict[str, int]:
66
+ normalized = [normalize_header(value) for value in row]
67
+ result: dict[str, int] = {}
68
+ for field, aliases in HEADER_ALIASES.items():
69
+ matches = [index for index, value in enumerate(normalized) if value in aliases]
70
+ if len(matches) == 1:
71
+ result[field] = matches[0]
72
+ return result
73
+
74
+
75
+ def _cell(row: tuple[Any, ...], columns: dict[str, int], field: str) -> Any:
76
+ index = columns.get(field)
77
+ return row[index] if index is not None and index < len(row) else None
78
+
79
+
80
+ def parse_worksheet(
81
+ workbook_path: str | Path,
82
+ sheet_name: str,
83
+ *,
84
+ reference: str,
85
+ source_file: str | None = None,
86
+ ) -> SheetParseResult:
87
+ path = Path(workbook_path)
88
+ workbook = load_workbook(path, read_only=True, data_only=True)
89
+ try:
90
+ if sheet_name not in workbook.sheetnames:
91
+ raise ValueError(f"Aba inexistente: {sheet_name}")
92
+ sheet = workbook[sheet_name]
93
+ regime = detect_regime(sheet_name)
94
+ rows = sheet.iter_rows(values_only=True)
95
+ header_row = None
96
+ columns: dict[str, int] = {}
97
+ uf: str | None = None
98
+ context_rows: list[Any] = []
99
+
100
+ for row_number, row in enumerate(rows, start=1):
101
+ context_rows.extend(row)
102
+ candidate = _column_map(tuple(row))
103
+ if {"code", "description", "unit", "price"}.issubset(candidate):
104
+ header_row = row_number
105
+ columns = candidate
106
+ uf = detect_uf(*context_rows)
107
+ break
108
+
109
+ if header_row is None:
110
+ return SheetParseResult(
111
+ sheet_name,
112
+ regime,
113
+ None,
114
+ None,
115
+ (),
116
+ ("Cabeçalho não reconhecido; layout requer mapeamento explícito.",),
117
+ )
118
+
119
+ records: list[PriceObservation] = []
120
+ warnings: list[str] = []
121
+ for row_number, row in enumerate(rows, start=header_row + 1):
122
+ values = tuple(row)
123
+ code = _cell(values, columns, "code")
124
+ description = _cell(values, columns, "description")
125
+ unit = _cell(values, columns, "unit")
126
+ raw_price = _cell(values, columns, "price")
127
+ raw_status = _cell(values, columns, "status")
128
+ if code is None and description is None:
129
+ continue
130
+ if code is None or description is None or unit is None:
131
+ warnings.append(f"Linha {row_number}: registro incompleto ignorado.")
132
+ continue
133
+ if uf is None or regime is None:
134
+ warnings.append(
135
+ f"Linha {row_number}: UF ou regime não identificados; registro não emitido."
136
+ )
137
+ continue
138
+ price, status = classify_price(raw_price, raw_status)
139
+ records.append(
140
+ PriceObservation(
141
+ reference=reference,
142
+ uf=uf,
143
+ regime=regime,
144
+ code=str(code).strip(),
145
+ description=str(description).strip(),
146
+ unit=str(unit).strip(),
147
+ price=price,
148
+ status=status,
149
+ source_file=source_file or path.name,
150
+ source_sheet=sheet_name,
151
+ source_row=row_number,
152
+ )
153
+ )
154
+ return SheetParseResult(
155
+ sheet_name, regime, uf, header_row, tuple(records), tuple(warnings)
156
+ )
157
+ finally:
158
+ workbook.close()
@@ -0,0 +1,126 @@
1
+ """Extração segura e inventário de planilhas XLSX de pacotes SINAPI."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import re
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from zipfile import BadZipFile
10
+
11
+ from openpyxl import load_workbook
12
+
13
+ from getsinapi.collectors.caixa import CaixaDownloadError, safe_extract_zip
14
+ from getsinapi.config import Settings
15
+
16
+
17
+ def _sha256(path: Path) -> str:
18
+ digest = hashlib.sha256()
19
+ with path.open("rb") as stream:
20
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
21
+ digest.update(chunk)
22
+ return digest.hexdigest()
23
+
24
+
25
+ def _json_value(value):
26
+ """Converte valores de células para tipos serializáveis sem alterar seu significado."""
27
+ if hasattr(value, "isoformat"):
28
+ return value.isoformat()
29
+ if isinstance(value, (str, int, float, bool)) or value is None:
30
+ return value
31
+ return str(value)
32
+
33
+
34
+ def inspect_workbook(path: Path) -> dict:
35
+ """Retorna metadados e amostra técnica das abas, sem inferir conteúdo de negócio."""
36
+ try:
37
+ workbook = load_workbook(path, read_only=True, data_only=True)
38
+ except (BadZipFile, OSError, ValueError) as exc:
39
+ raise CaixaDownloadError(f"Planilha XLSX inválida: {path.name}: {exc}") from exc
40
+
41
+ sheets = []
42
+ try:
43
+ for worksheet in workbook.worksheets:
44
+ preview = []
45
+ for row in worksheet.iter_rows(
46
+ min_row=1,
47
+ max_row=min(worksheet.max_row or 0, 12),
48
+ min_col=1,
49
+ max_col=min(worksheet.max_column or 0, 20),
50
+ values_only=True,
51
+ ):
52
+ values = [_json_value(value) for value in row]
53
+ if any(value is not None and value != "" for value in values):
54
+ preview.append(values)
55
+ sheets.append({
56
+ "name": worksheet.title,
57
+ "max_row": worksheet.max_row,
58
+ "max_column": worksheet.max_column,
59
+ "state": worksheet.sheet_state,
60
+ "preview_rows": preview,
61
+ })
62
+ finally:
63
+ workbook.close()
64
+
65
+ return {
66
+ "file": path.name,
67
+ "relative_path": path.name,
68
+ "size_bytes": path.stat().st_size,
69
+ "sha256": _sha256(path),
70
+ "sheets": sheets,
71
+ }
72
+
73
+
74
+ def extract_and_catalog(
75
+ archive_path: Path,
76
+ reference: str,
77
+ settings: Settings | None = None,
78
+ ) -> dict:
79
+ """Extrai um pacote oficial e gera catálogo JSON reproduzível."""
80
+ if not re.fullmatch(r"\d{4}-(0[1-9]|1[0-2])", reference):
81
+ raise ValueError("Competência inválida. Informe AAAA-MM.")
82
+
83
+ archive_path = Path(archive_path)
84
+ if not archive_path.is_file():
85
+ raise FileNotFoundError(f"Arquivo ZIP não encontrado: {archive_path}")
86
+ settings = settings or Settings.from_env()
87
+ settings.create_data_directories()
88
+
89
+ digest = _sha256(archive_path)
90
+ destination = settings.staging_dir / reference / digest[:12]
91
+ destination.mkdir(parents=True, exist_ok=True)
92
+
93
+ extracted = safe_extract_zip(archive_path, destination)
94
+ workbooks = []
95
+ for path in sorted(extracted):
96
+ if path.suffix.lower() == ".xlsx":
97
+ workbooks.append(inspect_workbook(path))
98
+
99
+ catalog = {
100
+ "schema_version": "1.0",
101
+ "source_archive": archive_path.name,
102
+ "source_sha256": digest,
103
+ "reference": reference,
104
+ "extracted_at": datetime.now(UTC).isoformat(),
105
+ "extracted_file_count": len(extracted),
106
+ "workbook_count": len(workbooks),
107
+ "files": [
108
+ {"path": path.relative_to(destination).as_posix(), "size_bytes": path.stat().st_size}
109
+ for path in sorted(extracted)
110
+ ],
111
+ "workbooks": workbooks,
112
+ }
113
+
114
+ catalog_dir = settings.manifest_dir / reference
115
+ catalog_dir.mkdir(parents=True, exist_ok=True)
116
+ catalog_path = catalog_dir / f"workbook-catalog-{digest[:12]}.json"
117
+ temporary = catalog_path.with_suffix(".json.part")
118
+ temporary.write_text(json.dumps(catalog, ensure_ascii=False, indent=2), encoding="utf-8")
119
+ temporary.replace(catalog_path)
120
+
121
+ return {
122
+ "files": extracted,
123
+ "workbooks": workbooks,
124
+ "catalog_path": catalog_path,
125
+ "catalog": catalog,
126
+ }
@@ -0,0 +1,114 @@
1
+
2
+ """Normalização conservadora de valores e observações de preço SINAPI."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import unicodedata
8
+ from decimal import Decimal, InvalidOperation
9
+ from typing import Any
10
+
11
+ from getsinapi.domain import PriceStatus
12
+
13
+
14
+ def _normalize_text(value: Any) -> str:
15
+ """Normaliza texto para comparação, preservando o conteúdo semântico."""
16
+ text = str(value).strip().casefold()
17
+ text = unicodedata.normalize("NFKD", text)
18
+ text = "".join(
19
+ char for char in text
20
+ if not unicodedata.combining(char)
21
+ )
22
+ return re.sub(r"\s+", " ", text)
23
+
24
+
25
+ _NOT_RESEARCHED = re.compile(
26
+ r"\b(?:"
27
+ r"nao pesquisad[oa]s?"
28
+ r"|sem pesquisa de preco"
29
+ r"|preco nao pesquisad[oa]"
30
+ r")\b",
31
+ flags=re.IGNORECASE,
32
+ )
33
+
34
+
35
+ def parse_decimal(value: Any) -> Decimal | None:
36
+ """Converte valores numéricos sem arredondamento; vazio permanece None.
37
+
38
+ Aceita Decimal, inteiros, floats e strings numéricas brasileiras.
39
+ Não interpreta textos de ausência como zero.
40
+ """
41
+ if value is None:
42
+ return None
43
+
44
+ if isinstance(value, bool):
45
+ raise TypeError("Booleano não é um valor monetário válido.")
46
+
47
+ if isinstance(value, Decimal):
48
+ result = value
49
+ elif isinstance(value, int):
50
+ result = Decimal(value)
51
+ elif isinstance(value, float):
52
+ result = Decimal(str(value))
53
+ else:
54
+ raw = str(value).strip()
55
+
56
+ if not raw:
57
+ return None
58
+
59
+ raw = raw.replace("R$", "").replace(" ", "")
60
+
61
+ if "," in raw and "." in raw:
62
+ if raw.rfind(",") > raw.rfind("."):
63
+ raw = raw.replace(".", "").replace(",", ".")
64
+ else:
65
+ raw = raw.replace(",", "")
66
+ elif "," in raw:
67
+ raw = raw.replace(".", "").replace(",", ".")
68
+
69
+ try:
70
+ result = Decimal(raw)
71
+ except InvalidOperation as exc:
72
+ raise ValueError(
73
+ f"Valor numérico inválido: {value!r}"
74
+ ) from exc
75
+
76
+ if not result.is_finite():
77
+ raise ValueError(
78
+ f"Valor numérico não finito: {value!r}"
79
+ )
80
+
81
+ return result
82
+
83
+
84
+ def classify_price(
85
+ value: Any,
86
+ source_status: Any = None,
87
+ ) -> tuple[Decimal | None, PriceStatus]:
88
+ """Classifica preços preservando as distinções da fonte.
89
+
90
+ - NOT_RESEARCHED: indicação textual explícita da fonte.
91
+ - MISSING: valor ausente ou célula vazia.
92
+ - REPORTED_ZERO: preço numérico igual a zero.
93
+ - REPORTED: preço numérico diferente de zero.
94
+
95
+ A ausência de informação nunca é convertida automaticamente em zero.
96
+ """
97
+ status_text = (
98
+ ""
99
+ if source_status is None
100
+ else _normalize_text(source_status)
101
+ )
102
+
103
+ if _NOT_RESEARCHED.search(status_text):
104
+ return None, PriceStatus.NOT_RESEARCHED
105
+
106
+ parsed = parse_decimal(value)
107
+
108
+ if parsed is None:
109
+ return None, PriceStatus.MISSING
110
+
111
+ if parsed == Decimal(0):
112
+ return parsed, PriceStatus.REPORTED_ZERO
113
+
114
+ return parsed, PriceStatus.REPORTED
@@ -0,0 +1 @@
1
+ """Orquestração das etapas de processamento SINAPI."""
@@ -0,0 +1,114 @@
1
+ """Publica os resultados normalizados do XLSX oficial na camada Silver."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from collections.abc import Iterable
9
+ from dataclasses import asdict
10
+ from decimal import Decimal
11
+ from enum import Enum
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from getsinapi.config import Settings
16
+ from getsinapi.extractors.reference_workbook import parse_reference_workbook
17
+
18
+
19
+ def _json_default(value: Any) -> str:
20
+ if isinstance(value, Decimal):
21
+ return str(value)
22
+ if isinstance(value, Enum):
23
+ return value.value
24
+ raise TypeError(f"Tipo não serializável: {type(value).__name__}")
25
+
26
+
27
+ def _write_jsonl(path: Path, records: Iterable[dict[str, Any]]) -> dict[str, Any]:
28
+ path.parent.mkdir(parents=True, exist_ok=True)
29
+ temporary = path.with_suffix(path.suffix + ".tmp")
30
+ digest = hashlib.sha256()
31
+ record_count = 0
32
+ with temporary.open("w", encoding="utf-8", newline="\n") as stream:
33
+ for record in records:
34
+ line = json.dumps(
35
+ record,
36
+ ensure_ascii=False,
37
+ sort_keys=True,
38
+ default=_json_default,
39
+ separators=(",", ":"),
40
+ ) + "\n"
41
+ stream.write(line)
42
+ digest.update(line.encode("utf-8"))
43
+ record_count += 1
44
+ os.replace(temporary, path)
45
+ return {
46
+ "file": path.name,
47
+ "records": record_count,
48
+ "sha256": digest.hexdigest(),
49
+ "size_bytes": path.stat().st_size,
50
+ }
51
+
52
+
53
+ def normalize_reference_workbook(
54
+ workbook_path: str | Path,
55
+ *,
56
+ reference: str,
57
+ settings: Settings | None = None,
58
+ ) -> dict[str, Any]:
59
+ """Extrai tabelas e grava JSONL rastreável em data/processed/AAAA-MM."""
60
+ config = settings or Settings.from_env()
61
+ result = parse_reference_workbook(workbook_path, reference=reference)
62
+ output_dir = config.processed_dir / reference
63
+
64
+ # Não materializa cópias adicionais de centenas de milhares de registros
65
+ # como listas de dicionários; serializa cada registro diretamente em JSONL.
66
+ def iter_price_records():
67
+ for table in result.prices:
68
+ for record in table.records:
69
+ yield {**asdict(record), "table_type": table.table_type}
70
+
71
+ artifacts = [
72
+ _write_jsonl(output_dir / "input_prices.jsonl", iter_price_records()),
73
+ _write_jsonl(
74
+ output_dir / "compositions.jsonl",
75
+ (asdict(record) for record in result.compositions.compositions),
76
+ ),
77
+ _write_jsonl(
78
+ output_dir / "composition_items.jsonl",
79
+ (asdict(record) for record in result.compositions.items),
80
+ ),
81
+ ]
82
+ counts_by_file = {item["file"]: item["records"] for item in artifacts}
83
+
84
+ warnings = [
85
+ {"source": table.table_type, "message": warning}
86
+ for table in result.prices
87
+ for warning in table.warnings
88
+ ] + [
89
+ {"source": "Analítico", "message": warning}
90
+ for warning in result.compositions.warnings
91
+ ]
92
+
93
+ manifest = {
94
+ "reference": reference,
95
+ "source_file": result.source_file,
96
+ "source_path": str(Path(workbook_path)),
97
+ "artifacts": artifacts,
98
+ "counts": {
99
+ "input_prices": counts_by_file["input_prices.jsonl"],
100
+ "compositions": counts_by_file["compositions.jsonl"],
101
+ "composition_items": counts_by_file["composition_items.jsonl"],
102
+ },
103
+ "warning_count": len(warnings),
104
+ "warnings": warnings,
105
+ }
106
+ manifest_path = output_dir / "normalization-manifest.json"
107
+ temporary_manifest = manifest_path.with_suffix(".json.tmp")
108
+ temporary_manifest.write_text(
109
+ json.dumps(manifest, ensure_ascii=False, indent=2, default=_json_default) + "\n",
110
+ encoding="utf-8",
111
+ )
112
+ os.replace(temporary_manifest, manifest_path)
113
+ manifest["manifest_path"] = str(manifest_path)
114
+ return manifest
@@ -0,0 +1 @@
1
+ """Serviços de domínio e cálculos do GET-SINAPI."""