agrobr 0.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.
- agrobr/__init__.py +10 -0
- agrobr/alerts/__init__.py +7 -0
- agrobr/alerts/notifier.py +167 -0
- agrobr/cache/__init__.py +31 -0
- agrobr/cache/duckdb_store.py +433 -0
- agrobr/cache/history.py +317 -0
- agrobr/cache/migrations.py +82 -0
- agrobr/cache/policies.py +240 -0
- agrobr/cepea/__init__.py +7 -0
- agrobr/cepea/api.py +360 -0
- agrobr/cepea/client.py +273 -0
- agrobr/cepea/parsers/__init__.py +37 -0
- agrobr/cepea/parsers/base.py +35 -0
- agrobr/cepea/parsers/consensus.py +300 -0
- agrobr/cepea/parsers/detector.py +108 -0
- agrobr/cepea/parsers/fingerprint.py +226 -0
- agrobr/cepea/parsers/v1.py +305 -0
- agrobr/cli.py +323 -0
- agrobr/conab/__init__.py +21 -0
- agrobr/conab/api.py +239 -0
- agrobr/conab/client.py +219 -0
- agrobr/conab/parsers/__init__.py +7 -0
- agrobr/conab/parsers/v1.py +383 -0
- agrobr/constants.py +205 -0
- agrobr/exceptions.py +104 -0
- agrobr/health/__init__.py +23 -0
- agrobr/health/checker.py +202 -0
- agrobr/health/reporter.py +314 -0
- agrobr/http/__init__.py +9 -0
- agrobr/http/browser.py +214 -0
- agrobr/http/rate_limiter.py +69 -0
- agrobr/http/retry.py +93 -0
- agrobr/http/user_agents.py +67 -0
- agrobr/ibge/__init__.py +19 -0
- agrobr/ibge/api.py +273 -0
- agrobr/ibge/client.py +256 -0
- agrobr/models.py +85 -0
- agrobr/normalize/__init__.py +64 -0
- agrobr/normalize/dates.py +303 -0
- agrobr/normalize/encoding.py +102 -0
- agrobr/normalize/regions.py +308 -0
- agrobr/normalize/units.py +278 -0
- agrobr/noticias_agricolas/__init__.py +6 -0
- agrobr/noticias_agricolas/client.py +222 -0
- agrobr/noticias_agricolas/parser.py +187 -0
- agrobr/sync.py +147 -0
- agrobr/telemetry/__init__.py +17 -0
- agrobr/telemetry/collector.py +153 -0
- agrobr/utils/__init__.py +5 -0
- agrobr/utils/logging.py +59 -0
- agrobr/validators/__init__.py +35 -0
- agrobr/validators/sanity.py +286 -0
- agrobr/validators/structural.py +313 -0
- agrobr-0.1.0.dist-info/METADATA +243 -0
- agrobr-0.1.0.dist-info/RECORD +58 -0
- agrobr-0.1.0.dist-info/WHEEL +4 -0
- agrobr-0.1.0.dist-info/entry_points.txt +2 -0
- agrobr-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilitários para datas de safra agrícola.
|
|
3
|
+
|
|
4
|
+
No Brasil, a safra agrícola não coincide com o ano civil:
|
|
5
|
+
- Safra de verão (soja, milho 1ª): plantio Set-Dez, colheita Jan-Abr
|
|
6
|
+
- Safra de inverno (milho 2ª, trigo): plantio Fev-Mar, colheita Jun-Set
|
|
7
|
+
|
|
8
|
+
Notação: "2024/25" significa plantio em 2024 e colheita em 2025.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from datetime import date
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
MesSafra = Literal[
|
|
18
|
+
"jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
REGEX_SAFRA_COMPLETA = re.compile(r"^(\d{4})/(\d{2})$")
|
|
22
|
+
REGEX_SAFRA_CURTA = re.compile(r"^(\d{2})/(\d{2})$")
|
|
23
|
+
REGEX_SAFRA_BARRA = re.compile(r"^(\d{4})/(\d{4})$")
|
|
24
|
+
|
|
25
|
+
INICIO_SAFRA_MES = 7
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def safra_atual(data: date | None = None) -> str:
|
|
29
|
+
"""
|
|
30
|
+
Retorna a safra agrícola atual no formato '2024/25'.
|
|
31
|
+
|
|
32
|
+
A safra é determinada pelo mês:
|
|
33
|
+
- Jul a Dez: safra do ano atual / próximo ano
|
|
34
|
+
- Jan a Jun: safra do ano anterior / ano atual
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
data: Data de referência (default: hoje)
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
String no formato '2024/25'
|
|
41
|
+
|
|
42
|
+
Examples:
|
|
43
|
+
>>> safra_atual(date(2024, 10, 15))
|
|
44
|
+
'2024/25'
|
|
45
|
+
>>> safra_atual(date(2025, 3, 15))
|
|
46
|
+
'2024/25'
|
|
47
|
+
"""
|
|
48
|
+
if data is None:
|
|
49
|
+
data = date.today()
|
|
50
|
+
|
|
51
|
+
ano_inicio = data.year if data.month >= INICIO_SAFRA_MES else data.year - 1
|
|
52
|
+
|
|
53
|
+
ano_fim = ano_inicio + 1
|
|
54
|
+
return f"{ano_inicio}/{str(ano_fim)[-2:]}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validar_safra(safra: str) -> bool:
|
|
58
|
+
"""
|
|
59
|
+
Valida se string está no formato de safra válido.
|
|
60
|
+
|
|
61
|
+
Formatos aceitos:
|
|
62
|
+
- '2024/25' (padrão)
|
|
63
|
+
- '24/25' (curto)
|
|
64
|
+
- '2024/2025' (completo)
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
safra: String a validar
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
True se válido, False caso contrário
|
|
71
|
+
"""
|
|
72
|
+
if REGEX_SAFRA_COMPLETA.match(safra):
|
|
73
|
+
return True
|
|
74
|
+
if REGEX_SAFRA_CURTA.match(safra):
|
|
75
|
+
return True
|
|
76
|
+
return bool(REGEX_SAFRA_BARRA.match(safra))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def normalizar_safra(safra: str) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Normaliza safra para formato padrão '2024/25'.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
safra: Safra em qualquer formato aceito
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Safra no formato '2024/25'
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ValueError: Se formato inválido
|
|
91
|
+
|
|
92
|
+
Examples:
|
|
93
|
+
>>> normalizar_safra('24/25')
|
|
94
|
+
'2024/25'
|
|
95
|
+
>>> normalizar_safra('2024/2025')
|
|
96
|
+
'2024/25'
|
|
97
|
+
"""
|
|
98
|
+
match_completa = REGEX_SAFRA_COMPLETA.match(safra)
|
|
99
|
+
if match_completa:
|
|
100
|
+
return safra
|
|
101
|
+
|
|
102
|
+
match_curta = REGEX_SAFRA_CURTA.match(safra)
|
|
103
|
+
if match_curta:
|
|
104
|
+
ano_inicio = int(match_curta.group(1))
|
|
105
|
+
ano_fim = match_curta.group(2)
|
|
106
|
+
ano_inicio = 1900 + ano_inicio if ano_inicio >= 50 else 2000 + ano_inicio
|
|
107
|
+
return f"{ano_inicio}/{ano_fim}"
|
|
108
|
+
|
|
109
|
+
match_barra = REGEX_SAFRA_BARRA.match(safra)
|
|
110
|
+
if match_barra:
|
|
111
|
+
ano_inicio_str = match_barra.group(1)
|
|
112
|
+
ano_fim_str = match_barra.group(2)[-2:]
|
|
113
|
+
return f"{ano_inicio_str}/{ano_fim_str}"
|
|
114
|
+
|
|
115
|
+
raise ValueError(f"Formato de safra inválido: '{safra}'")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def safra_para_anos(safra: str) -> tuple[int, int]:
|
|
119
|
+
"""
|
|
120
|
+
Converte safra para anos de início e fim.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
safra: Safra no formato '2024/25'
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Tupla (ano_inicio, ano_fim)
|
|
127
|
+
|
|
128
|
+
Examples:
|
|
129
|
+
>>> safra_para_anos('2024/25')
|
|
130
|
+
(2024, 2025)
|
|
131
|
+
"""
|
|
132
|
+
safra_norm = normalizar_safra(safra)
|
|
133
|
+
match = REGEX_SAFRA_COMPLETA.match(safra_norm)
|
|
134
|
+
|
|
135
|
+
if match is None:
|
|
136
|
+
raise ValueError(f"Formato de safra inválido: '{safra}'")
|
|
137
|
+
|
|
138
|
+
ano_inicio = int(match.group(1))
|
|
139
|
+
ano_fim_curto = int(match.group(2))
|
|
140
|
+
|
|
141
|
+
seculo = (ano_inicio // 100) * 100
|
|
142
|
+
ano_fim = seculo + ano_fim_curto
|
|
143
|
+
|
|
144
|
+
if ano_fim < ano_inicio:
|
|
145
|
+
ano_fim += 100
|
|
146
|
+
|
|
147
|
+
return ano_inicio, ano_fim
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def anos_para_safra(ano_inicio: int, ano_fim: int | None = None) -> str:
|
|
151
|
+
"""
|
|
152
|
+
Converte anos para formato de safra.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
ano_inicio: Ano de início (plantio)
|
|
156
|
+
ano_fim: Ano de fim (colheita). Se None, assume ano_inicio + 1
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Safra no formato '2024/25'
|
|
160
|
+
|
|
161
|
+
Examples:
|
|
162
|
+
>>> anos_para_safra(2024)
|
|
163
|
+
'2024/25'
|
|
164
|
+
>>> anos_para_safra(2024, 2025)
|
|
165
|
+
'2024/25'
|
|
166
|
+
"""
|
|
167
|
+
if ano_fim is None:
|
|
168
|
+
ano_fim = ano_inicio + 1
|
|
169
|
+
|
|
170
|
+
return f"{ano_inicio}/{str(ano_fim)[-2:]}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def safra_anterior(safra: str, n: int = 1) -> str:
|
|
174
|
+
"""
|
|
175
|
+
Retorna a safra N anos antes.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
safra: Safra de referência
|
|
179
|
+
n: Número de safras anteriores (default: 1)
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Safra anterior
|
|
183
|
+
|
|
184
|
+
Examples:
|
|
185
|
+
>>> safra_anterior('2024/25')
|
|
186
|
+
'2023/24'
|
|
187
|
+
>>> safra_anterior('2024/25', 3)
|
|
188
|
+
'2021/22'
|
|
189
|
+
"""
|
|
190
|
+
ano_inicio, _ = safra_para_anos(safra)
|
|
191
|
+
return anos_para_safra(ano_inicio - n)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def safra_posterior(safra: str, n: int = 1) -> str:
|
|
195
|
+
"""
|
|
196
|
+
Retorna a safra N anos depois.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
safra: Safra de referência
|
|
200
|
+
n: Número de safras posteriores (default: 1)
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Safra posterior
|
|
204
|
+
|
|
205
|
+
Examples:
|
|
206
|
+
>>> safra_posterior('2024/25')
|
|
207
|
+
'2025/26'
|
|
208
|
+
"""
|
|
209
|
+
ano_inicio, _ = safra_para_anos(safra)
|
|
210
|
+
return anos_para_safra(ano_inicio + n)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def lista_safras(inicio: str, fim: str) -> list[str]:
|
|
214
|
+
"""
|
|
215
|
+
Gera lista de safras entre início e fim (inclusive).
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
inicio: Safra inicial
|
|
219
|
+
fim: Safra final
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Lista de safras
|
|
223
|
+
|
|
224
|
+
Examples:
|
|
225
|
+
>>> lista_safras('2020/21', '2024/25')
|
|
226
|
+
['2020/21', '2021/22', '2022/23', '2023/24', '2024/25']
|
|
227
|
+
"""
|
|
228
|
+
ano_inicio, _ = safra_para_anos(inicio)
|
|
229
|
+
ano_fim, _ = safra_para_anos(fim)
|
|
230
|
+
|
|
231
|
+
return [anos_para_safra(ano) for ano in range(ano_inicio, ano_fim + 1)]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def data_para_safra(data: date) -> str:
|
|
235
|
+
"""
|
|
236
|
+
Determina a safra de uma data.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
data: Data
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Safra correspondente
|
|
243
|
+
"""
|
|
244
|
+
return safra_atual(data)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def periodo_safra(safra: str) -> tuple[date, date]:
|
|
248
|
+
"""
|
|
249
|
+
Retorna período aproximado da safra (Jul a Jun).
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
safra: Safra no formato '2024/25'
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Tupla (data_inicio, data_fim)
|
|
256
|
+
"""
|
|
257
|
+
ano_inicio, ano_fim = safra_para_anos(safra)
|
|
258
|
+
|
|
259
|
+
data_inicio = date(ano_inicio, INICIO_SAFRA_MES, 1)
|
|
260
|
+
data_fim = date(ano_fim, 6, 30)
|
|
261
|
+
|
|
262
|
+
return data_inicio, data_fim
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def mes_para_numero(mes: str | MesSafra) -> int:
|
|
266
|
+
"""
|
|
267
|
+
Converte nome do mês para número.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
mes: Nome do mês (pt-BR, 3 letras)
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Número do mês (1-12)
|
|
274
|
+
"""
|
|
275
|
+
meses = {
|
|
276
|
+
"jan": 1,
|
|
277
|
+
"fev": 2,
|
|
278
|
+
"mar": 3,
|
|
279
|
+
"abr": 4,
|
|
280
|
+
"mai": 5,
|
|
281
|
+
"jun": 6,
|
|
282
|
+
"jul": 7,
|
|
283
|
+
"ago": 8,
|
|
284
|
+
"set": 9,
|
|
285
|
+
"out": 10,
|
|
286
|
+
"nov": 11,
|
|
287
|
+
"dez": 12,
|
|
288
|
+
}
|
|
289
|
+
return meses[mes.lower()[:3]]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def numero_para_mes(numero: int) -> str:
|
|
293
|
+
"""
|
|
294
|
+
Converte número do mês para nome.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
numero: Número do mês (1-12)
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
Nome do mês (3 letras)
|
|
301
|
+
"""
|
|
302
|
+
meses = ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"]
|
|
303
|
+
return meses[numero - 1]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Tratamento de encoding com fallback chain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
import chardet
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
logger = structlog.get_logger()
|
|
11
|
+
|
|
12
|
+
ENCODING_CHAIN: Sequence[str] = (
|
|
13
|
+
"utf-8",
|
|
14
|
+
"iso-8859-1",
|
|
15
|
+
"windows-1252",
|
|
16
|
+
"utf-16",
|
|
17
|
+
"ascii",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def decode_content(
|
|
22
|
+
content: bytes,
|
|
23
|
+
declared_encoding: str | None = None,
|
|
24
|
+
source: str | None = None,
|
|
25
|
+
) -> tuple[str, str]:
|
|
26
|
+
"""
|
|
27
|
+
Decodifica bytes com fallback chain inteligente.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
content: Bytes a decodificar
|
|
31
|
+
declared_encoding: Encoding declarado pelo servidor (Content-Type)
|
|
32
|
+
source: Nome da fonte para logging
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
tuple[str, str]: (texto decodificado, encoding usado)
|
|
36
|
+
"""
|
|
37
|
+
if declared_encoding:
|
|
38
|
+
try:
|
|
39
|
+
decoded = content.decode(declared_encoding)
|
|
40
|
+
logger.debug(
|
|
41
|
+
"encoding_success",
|
|
42
|
+
source=source,
|
|
43
|
+
encoding=declared_encoding,
|
|
44
|
+
method="declared",
|
|
45
|
+
)
|
|
46
|
+
return decoded, declared_encoding
|
|
47
|
+
except (UnicodeDecodeError, LookupError):
|
|
48
|
+
logger.debug(
|
|
49
|
+
"encoding_declared_failed",
|
|
50
|
+
source=source,
|
|
51
|
+
declared=declared_encoding,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
for encoding in ENCODING_CHAIN:
|
|
55
|
+
try:
|
|
56
|
+
decoded = content.decode(encoding)
|
|
57
|
+
if encoding != "utf-8":
|
|
58
|
+
logger.info(
|
|
59
|
+
"encoding_fallback",
|
|
60
|
+
source=source,
|
|
61
|
+
declared=declared_encoding,
|
|
62
|
+
actual=encoding,
|
|
63
|
+
method="chain",
|
|
64
|
+
)
|
|
65
|
+
return decoded, encoding
|
|
66
|
+
except UnicodeDecodeError:
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
detected = chardet.detect(content)
|
|
70
|
+
if detected["encoding"] and detected["confidence"] > 0.7:
|
|
71
|
+
try:
|
|
72
|
+
decoded = content.decode(detected["encoding"])
|
|
73
|
+
logger.info(
|
|
74
|
+
"encoding_fallback",
|
|
75
|
+
source=source,
|
|
76
|
+
declared=declared_encoding,
|
|
77
|
+
actual=detected["encoding"],
|
|
78
|
+
confidence=detected["confidence"],
|
|
79
|
+
method="chardet",
|
|
80
|
+
)
|
|
81
|
+
return decoded, detected["encoding"]
|
|
82
|
+
except (UnicodeDecodeError, LookupError):
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
logger.warning(
|
|
86
|
+
"encoding_forced",
|
|
87
|
+
source=source,
|
|
88
|
+
declared=declared_encoding,
|
|
89
|
+
chardet_result=detected,
|
|
90
|
+
)
|
|
91
|
+
return content.decode("utf-8", errors="replace"), "utf-8-replaced"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def detect_encoding(content: bytes) -> tuple[str, float]:
|
|
95
|
+
"""
|
|
96
|
+
Detecta encoding provável do conteúdo.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
tuple[str, float]: (encoding, confidence 0-1)
|
|
100
|
+
"""
|
|
101
|
+
result = chardet.detect(content)
|
|
102
|
+
return result["encoding"] or "utf-8", result["confidence"] or 0.0
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Padronização de regiões, UFs e municípios brasileiros.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import unicodedata
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
UF = Literal[
|
|
12
|
+
"AC",
|
|
13
|
+
"AL",
|
|
14
|
+
"AP",
|
|
15
|
+
"AM",
|
|
16
|
+
"BA",
|
|
17
|
+
"CE",
|
|
18
|
+
"DF",
|
|
19
|
+
"ES",
|
|
20
|
+
"GO",
|
|
21
|
+
"MA",
|
|
22
|
+
"MT",
|
|
23
|
+
"MS",
|
|
24
|
+
"MG",
|
|
25
|
+
"PA",
|
|
26
|
+
"PB",
|
|
27
|
+
"PR",
|
|
28
|
+
"PE",
|
|
29
|
+
"PI",
|
|
30
|
+
"RJ",
|
|
31
|
+
"RN",
|
|
32
|
+
"RS",
|
|
33
|
+
"RO",
|
|
34
|
+
"RR",
|
|
35
|
+
"SC",
|
|
36
|
+
"SP",
|
|
37
|
+
"SE",
|
|
38
|
+
"TO",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
Regiao = Literal["Norte", "Nordeste", "Centro-Oeste", "Sudeste", "Sul"]
|
|
42
|
+
|
|
43
|
+
UFS: dict[str, dict[str, str | int]] = {
|
|
44
|
+
"AC": {"nome": "Acre", "regiao": "Norte", "ibge": 12},
|
|
45
|
+
"AL": {"nome": "Alagoas", "regiao": "Nordeste", "ibge": 27},
|
|
46
|
+
"AP": {"nome": "Amapá", "regiao": "Norte", "ibge": 16},
|
|
47
|
+
"AM": {"nome": "Amazonas", "regiao": "Norte", "ibge": 13},
|
|
48
|
+
"BA": {"nome": "Bahia", "regiao": "Nordeste", "ibge": 29},
|
|
49
|
+
"CE": {"nome": "Ceará", "regiao": "Nordeste", "ibge": 23},
|
|
50
|
+
"DF": {"nome": "Distrito Federal", "regiao": "Centro-Oeste", "ibge": 53},
|
|
51
|
+
"ES": {"nome": "Espírito Santo", "regiao": "Sudeste", "ibge": 32},
|
|
52
|
+
"GO": {"nome": "Goiás", "regiao": "Centro-Oeste", "ibge": 52},
|
|
53
|
+
"MA": {"nome": "Maranhão", "regiao": "Nordeste", "ibge": 21},
|
|
54
|
+
"MT": {"nome": "Mato Grosso", "regiao": "Centro-Oeste", "ibge": 51},
|
|
55
|
+
"MS": {"nome": "Mato Grosso do Sul", "regiao": "Centro-Oeste", "ibge": 50},
|
|
56
|
+
"MG": {"nome": "Minas Gerais", "regiao": "Sudeste", "ibge": 31},
|
|
57
|
+
"PA": {"nome": "Pará", "regiao": "Norte", "ibge": 15},
|
|
58
|
+
"PB": {"nome": "Paraíba", "regiao": "Nordeste", "ibge": 25},
|
|
59
|
+
"PR": {"nome": "Paraná", "regiao": "Sul", "ibge": 41},
|
|
60
|
+
"PE": {"nome": "Pernambuco", "regiao": "Nordeste", "ibge": 26},
|
|
61
|
+
"PI": {"nome": "Piauí", "regiao": "Nordeste", "ibge": 22},
|
|
62
|
+
"RJ": {"nome": "Rio de Janeiro", "regiao": "Sudeste", "ibge": 33},
|
|
63
|
+
"RN": {"nome": "Rio Grande do Norte", "regiao": "Nordeste", "ibge": 24},
|
|
64
|
+
"RS": {"nome": "Rio Grande do Sul", "regiao": "Sul", "ibge": 43},
|
|
65
|
+
"RO": {"nome": "Rondônia", "regiao": "Norte", "ibge": 11},
|
|
66
|
+
"RR": {"nome": "Roraima", "regiao": "Norte", "ibge": 14},
|
|
67
|
+
"SC": {"nome": "Santa Catarina", "regiao": "Sul", "ibge": 42},
|
|
68
|
+
"SP": {"nome": "São Paulo", "regiao": "Sudeste", "ibge": 35},
|
|
69
|
+
"SE": {"nome": "Sergipe", "regiao": "Nordeste", "ibge": 28},
|
|
70
|
+
"TO": {"nome": "Tocantins", "regiao": "Norte", "ibge": 17},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
REGIOES: dict[str, list[str]] = {
|
|
74
|
+
"Norte": ["AC", "AP", "AM", "PA", "RO", "RR", "TO"],
|
|
75
|
+
"Nordeste": ["AL", "BA", "CE", "MA", "PB", "PE", "PI", "RN", "SE"],
|
|
76
|
+
"Centro-Oeste": ["DF", "GO", "MT", "MS"],
|
|
77
|
+
"Sudeste": ["ES", "MG", "RJ", "SP"],
|
|
78
|
+
"Sul": ["PR", "RS", "SC"],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def remover_acentos(texto: str) -> str:
|
|
83
|
+
"""Remove acentos de uma string."""
|
|
84
|
+
nfkd = unicodedata.normalize("NFKD", texto)
|
|
85
|
+
return "".join(c for c in nfkd if not unicodedata.combining(c))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
NOMES_PARA_UF: dict[str, str] = {
|
|
89
|
+
remover_acentos(str(info["nome"]).lower()): uf for uf, info in UFS.items()
|
|
90
|
+
} | {uf.lower(): uf for uf in UFS}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def normalizar_uf(entrada: str) -> str | None:
|
|
94
|
+
"""
|
|
95
|
+
Normaliza entrada para sigla UF.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
entrada: Sigla ou nome do estado
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Sigla UF ou None se não encontrado
|
|
102
|
+
|
|
103
|
+
Examples:
|
|
104
|
+
>>> normalizar_uf('mato grosso')
|
|
105
|
+
'MT'
|
|
106
|
+
>>> normalizar_uf('SP')
|
|
107
|
+
'SP'
|
|
108
|
+
>>> normalizar_uf('são paulo')
|
|
109
|
+
'SP'
|
|
110
|
+
"""
|
|
111
|
+
entrada_norm = remover_acentos(entrada.strip().lower())
|
|
112
|
+
|
|
113
|
+
if entrada_norm.upper() in UFS:
|
|
114
|
+
return entrada_norm.upper()
|
|
115
|
+
|
|
116
|
+
if entrada_norm in NOMES_PARA_UF:
|
|
117
|
+
return NOMES_PARA_UF[entrada_norm]
|
|
118
|
+
|
|
119
|
+
for nome, uf in NOMES_PARA_UF.items():
|
|
120
|
+
if nome in entrada_norm or entrada_norm in nome:
|
|
121
|
+
return uf
|
|
122
|
+
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def uf_para_nome(uf: str) -> str:
|
|
127
|
+
"""
|
|
128
|
+
Retorna nome completo da UF.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
uf: Sigla da UF
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Nome completo
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
KeyError: Se UF inválida
|
|
138
|
+
"""
|
|
139
|
+
return str(UFS[uf.upper()]["nome"])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def uf_para_regiao(uf: str) -> str:
|
|
143
|
+
"""
|
|
144
|
+
Retorna região da UF.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
uf: Sigla da UF
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Nome da região
|
|
151
|
+
"""
|
|
152
|
+
return str(UFS[uf.upper()]["regiao"])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def uf_para_ibge(uf: str) -> int:
|
|
156
|
+
"""
|
|
157
|
+
Retorna código IBGE da UF.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
uf: Sigla da UF
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Código IBGE (2 dígitos)
|
|
164
|
+
"""
|
|
165
|
+
return int(UFS[uf.upper()]["ibge"])
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def ibge_para_uf(codigo: int) -> str:
|
|
169
|
+
"""
|
|
170
|
+
Converte código IBGE para sigla UF.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
codigo: Código IBGE
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Sigla UF
|
|
177
|
+
"""
|
|
178
|
+
for uf, info in UFS.items():
|
|
179
|
+
if info["ibge"] == codigo:
|
|
180
|
+
return uf
|
|
181
|
+
raise ValueError(f"Código IBGE inválido: {codigo}")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def listar_ufs(regiao: str | None = None) -> list[str]:
|
|
185
|
+
"""
|
|
186
|
+
Lista UFs, opcionalmente filtradas por região.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
regiao: Filtrar por região
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Lista de siglas UF
|
|
193
|
+
"""
|
|
194
|
+
if regiao:
|
|
195
|
+
return REGIOES.get(regiao, [])
|
|
196
|
+
return list(UFS.keys())
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def listar_regioes() -> list[str]:
|
|
200
|
+
"""Retorna lista de regiões."""
|
|
201
|
+
return list(REGIOES.keys())
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def normalizar_municipio(nome: str) -> str:
|
|
205
|
+
"""
|
|
206
|
+
Normaliza nome de município.
|
|
207
|
+
|
|
208
|
+
Remove acentos, converte para title case, trata casos especiais.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
nome: Nome do município
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Nome normalizado
|
|
215
|
+
"""
|
|
216
|
+
nome = nome.strip()
|
|
217
|
+
|
|
218
|
+
nome = re.sub(r"\s+", " ", nome)
|
|
219
|
+
|
|
220
|
+
palavras_minusculas = {"de", "da", "do", "das", "dos", "e"}
|
|
221
|
+
|
|
222
|
+
partes = nome.lower().split()
|
|
223
|
+
resultado = []
|
|
224
|
+
|
|
225
|
+
for i, parte in enumerate(partes):
|
|
226
|
+
if i == 0 or parte not in palavras_minusculas:
|
|
227
|
+
resultado.append(parte.capitalize())
|
|
228
|
+
else:
|
|
229
|
+
resultado.append(parte)
|
|
230
|
+
|
|
231
|
+
return " ".join(resultado)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def extrair_uf_municipio(texto: str) -> tuple[str | None, str | None]:
|
|
235
|
+
"""
|
|
236
|
+
Extrai UF e município de texto no formato "Município - UF" ou "Município/UF".
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
texto: Texto com município e UF
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Tupla (uf, municipio) ou (None, None) se não identificado
|
|
243
|
+
"""
|
|
244
|
+
padrao = re.compile(r"^(.+?)[\s]*[-/][\s]*([A-Za-z]{2})$")
|
|
245
|
+
match = padrao.match(texto.strip())
|
|
246
|
+
|
|
247
|
+
if match:
|
|
248
|
+
municipio = normalizar_municipio(match.group(1))
|
|
249
|
+
uf = normalizar_uf(match.group(2))
|
|
250
|
+
return uf, municipio
|
|
251
|
+
|
|
252
|
+
return None, None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def validar_uf(uf: str) -> bool:
|
|
256
|
+
"""Verifica se UF é válida."""
|
|
257
|
+
return uf.upper() in UFS
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def validar_regiao(regiao: str) -> bool:
|
|
261
|
+
"""Verifica se região é válida."""
|
|
262
|
+
return regiao in REGIOES
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
PRACAS_CEPEA: dict[str, dict[str, str]] = {
|
|
266
|
+
"soja": {
|
|
267
|
+
"paranagua": "PR",
|
|
268
|
+
"rio grande": "RS",
|
|
269
|
+
"santos": "SP",
|
|
270
|
+
},
|
|
271
|
+
"milho": {
|
|
272
|
+
"campinas": "SP",
|
|
273
|
+
"cascavel": "PR",
|
|
274
|
+
"rio verde": "GO",
|
|
275
|
+
},
|
|
276
|
+
"boi_gordo": {
|
|
277
|
+
"sao paulo": "SP",
|
|
278
|
+
"araçatuba": "SP",
|
|
279
|
+
"presidente prudente": "SP",
|
|
280
|
+
},
|
|
281
|
+
"cafe": {
|
|
282
|
+
"sao paulo": "SP",
|
|
283
|
+
"mogiana": "SP",
|
|
284
|
+
"sul de minas": "MG",
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def normalizar_praca(praca: str, produto: str | None = None) -> str:
|
|
290
|
+
"""
|
|
291
|
+
Normaliza nome de praça de comercialização.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
praca: Nome da praça
|
|
295
|
+
produto: Produto (para contexto)
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
Nome normalizado
|
|
299
|
+
"""
|
|
300
|
+
praca_norm = remover_acentos(praca.lower().strip())
|
|
301
|
+
|
|
302
|
+
if produto and produto.lower() in PRACAS_CEPEA:
|
|
303
|
+
pracas_produto = PRACAS_CEPEA[produto.lower()]
|
|
304
|
+
for praca_padrao in pracas_produto:
|
|
305
|
+
if praca_padrao in praca_norm or praca_norm in praca_padrao:
|
|
306
|
+
return praca_padrao.title()
|
|
307
|
+
|
|
308
|
+
return normalizar_municipio(praca)
|