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.
Files changed (58) hide show
  1. agrobr/__init__.py +10 -0
  2. agrobr/alerts/__init__.py +7 -0
  3. agrobr/alerts/notifier.py +167 -0
  4. agrobr/cache/__init__.py +31 -0
  5. agrobr/cache/duckdb_store.py +433 -0
  6. agrobr/cache/history.py +317 -0
  7. agrobr/cache/migrations.py +82 -0
  8. agrobr/cache/policies.py +240 -0
  9. agrobr/cepea/__init__.py +7 -0
  10. agrobr/cepea/api.py +360 -0
  11. agrobr/cepea/client.py +273 -0
  12. agrobr/cepea/parsers/__init__.py +37 -0
  13. agrobr/cepea/parsers/base.py +35 -0
  14. agrobr/cepea/parsers/consensus.py +300 -0
  15. agrobr/cepea/parsers/detector.py +108 -0
  16. agrobr/cepea/parsers/fingerprint.py +226 -0
  17. agrobr/cepea/parsers/v1.py +305 -0
  18. agrobr/cli.py +323 -0
  19. agrobr/conab/__init__.py +21 -0
  20. agrobr/conab/api.py +239 -0
  21. agrobr/conab/client.py +219 -0
  22. agrobr/conab/parsers/__init__.py +7 -0
  23. agrobr/conab/parsers/v1.py +383 -0
  24. agrobr/constants.py +205 -0
  25. agrobr/exceptions.py +104 -0
  26. agrobr/health/__init__.py +23 -0
  27. agrobr/health/checker.py +202 -0
  28. agrobr/health/reporter.py +314 -0
  29. agrobr/http/__init__.py +9 -0
  30. agrobr/http/browser.py +214 -0
  31. agrobr/http/rate_limiter.py +69 -0
  32. agrobr/http/retry.py +93 -0
  33. agrobr/http/user_agents.py +67 -0
  34. agrobr/ibge/__init__.py +19 -0
  35. agrobr/ibge/api.py +273 -0
  36. agrobr/ibge/client.py +256 -0
  37. agrobr/models.py +85 -0
  38. agrobr/normalize/__init__.py +64 -0
  39. agrobr/normalize/dates.py +303 -0
  40. agrobr/normalize/encoding.py +102 -0
  41. agrobr/normalize/regions.py +308 -0
  42. agrobr/normalize/units.py +278 -0
  43. agrobr/noticias_agricolas/__init__.py +6 -0
  44. agrobr/noticias_agricolas/client.py +222 -0
  45. agrobr/noticias_agricolas/parser.py +187 -0
  46. agrobr/sync.py +147 -0
  47. agrobr/telemetry/__init__.py +17 -0
  48. agrobr/telemetry/collector.py +153 -0
  49. agrobr/utils/__init__.py +5 -0
  50. agrobr/utils/logging.py +59 -0
  51. agrobr/validators/__init__.py +35 -0
  52. agrobr/validators/sanity.py +286 -0
  53. agrobr/validators/structural.py +313 -0
  54. agrobr-0.1.0.dist-info/METADATA +243 -0
  55. agrobr-0.1.0.dist-info/RECORD +58 -0
  56. agrobr-0.1.0.dist-info/WHEEL +4 -0
  57. agrobr-0.1.0.dist-info/entry_points.txt +2 -0
  58. 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)