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,383 @@
1
+ """Parser v1 para planilhas XLSX da CONAB."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from decimal import Decimal, InvalidOperation
7
+ from io import BytesIO
8
+ from typing import Any, cast
9
+
10
+ import pandas as pd
11
+ import structlog
12
+
13
+ from agrobr import constants
14
+ from agrobr.exceptions import ParseError
15
+ from agrobr.models import Safra
16
+
17
+ logger = structlog.get_logger()
18
+
19
+
20
+ class ConabParserV1:
21
+ """Parser para planilhas XLSX de safras da CONAB."""
22
+
23
+ version: int = 1
24
+ source: str = "conab"
25
+ valid_from: date = date(2020, 1, 1)
26
+ valid_until: date | None = None
27
+
28
+ def parse_safra_produto(
29
+ self,
30
+ xlsx: BytesIO,
31
+ produto: str,
32
+ safra_ref: str | None = None,
33
+ levantamento: int | None = None,
34
+ ) -> list[Safra]:
35
+ """
36
+ Extrai dados de safra por produto.
37
+
38
+ Args:
39
+ xlsx: BytesIO com arquivo XLSX
40
+ produto: Nome do produto (soja, milho, etc)
41
+ safra_ref: Safra de referência (ex: "2025/26")
42
+ levantamento: Número do levantamento
43
+
44
+ Returns:
45
+ Lista de objetos Safra por UF
46
+
47
+ Raises:
48
+ ParseError: Se não conseguir parsear os dados
49
+ """
50
+ sheet_name = constants.CONAB_PRODUTOS.get(produto.lower())
51
+ if not sheet_name:
52
+ raise ParseError(
53
+ source="conab",
54
+ parser_version=self.version,
55
+ reason=f"Produto não suportado: {produto}",
56
+ )
57
+
58
+ try:
59
+ df = pd.read_excel(xlsx, sheet_name=sheet_name, header=None)
60
+ except Exception as e:
61
+ raise ParseError(
62
+ source="conab",
63
+ parser_version=self.version,
64
+ reason=f"Erro ao ler aba {sheet_name}: {e}",
65
+ ) from e
66
+
67
+ header_row = self._find_header_row(df)
68
+ if header_row is None:
69
+ raise ParseError(
70
+ source="conab",
71
+ parser_version=self.version,
72
+ reason=f"Não encontrou header na aba {sheet_name}",
73
+ )
74
+
75
+ safras = []
76
+ data_row = header_row + 3
77
+
78
+ safra_cols = self._extract_safra_columns(df, header_row)
79
+
80
+ for idx in range(data_row, len(df)):
81
+ row = df.iloc[idx]
82
+ uf = str(row.iloc[0]).strip() if pd.notna(row.iloc[0]) else None
83
+
84
+ if not uf or uf in ["NaN", "nan", ""]:
85
+ continue
86
+
87
+ if uf.upper() in constants.CONAB_REGIOES:
88
+ continue
89
+
90
+ if uf.upper() not in constants.CONAB_UFS and not any(c.isalpha() for c in uf):
91
+ continue
92
+
93
+ for safra_str, cols in safra_cols.items():
94
+ if safra_ref and safra_str != safra_ref:
95
+ continue
96
+
97
+ area = self._parse_decimal(row.iloc[cols["area"]])
98
+ produtividade = self._parse_decimal(row.iloc[cols["produtividade"]])
99
+ producao = self._parse_decimal(row.iloc[cols["producao"]])
100
+
101
+ if area is None and producao is None:
102
+ continue
103
+
104
+ try:
105
+ safra = Safra(
106
+ fonte=constants.Fonte.CONAB,
107
+ produto=produto.lower(),
108
+ safra=safra_str,
109
+ uf=uf.upper() if len(uf) == 2 else None,
110
+ area_plantada=area,
111
+ producao=producao,
112
+ produtividade=produtividade,
113
+ unidade_area="mil_ha",
114
+ unidade_producao="mil_ton",
115
+ levantamento=levantamento or 1,
116
+ data_publicacao=date.today(),
117
+ parser_version=self.version,
118
+ )
119
+ safras.append(safra)
120
+ except Exception as e:
121
+ logger.warning(
122
+ "conab_parse_row_error",
123
+ uf=uf,
124
+ safra=safra_str,
125
+ error=str(e),
126
+ )
127
+
128
+ logger.info(
129
+ "conab_parse_safra_success",
130
+ produto=produto,
131
+ records=len(safras),
132
+ )
133
+
134
+ return safras
135
+
136
+ def parse_suprimento(
137
+ self,
138
+ xlsx: BytesIO,
139
+ produto: str | None = None,
140
+ ) -> list[dict[str, Any]]:
141
+ """
142
+ Extrai dados de balanço de oferta/demanda.
143
+
144
+ Args:
145
+ xlsx: BytesIO com arquivo XLSX
146
+ produto: Filtrar por produto (opcional)
147
+
148
+ Returns:
149
+ Lista de dicts com dados de suprimento
150
+ """
151
+ try:
152
+ df = pd.read_excel(xlsx, sheet_name="Suprimento", header=None)
153
+ except Exception as e:
154
+ raise ParseError(
155
+ source="conab",
156
+ parser_version=self.version,
157
+ reason=f"Erro ao ler aba Suprimento: {e}",
158
+ ) from e
159
+
160
+ header_row = None
161
+ for idx, row in df.iterrows():
162
+ if "PRODUTO" in str(row.iloc[0]).upper():
163
+ header_row = idx
164
+ break
165
+
166
+ if header_row is None:
167
+ raise ParseError(
168
+ source="conab",
169
+ parser_version=self.version,
170
+ reason="Não encontrou header na aba Suprimento",
171
+ )
172
+
173
+ suprimentos = []
174
+ current_produto = None
175
+
176
+ for idx in range(cast(int, header_row) + 1, len(df)):
177
+ row = df.iloc[idx]
178
+
179
+ produto_cell = str(row.iloc[0]).strip() if pd.notna(row.iloc[0]) else None
180
+ if produto_cell and produto_cell not in ["NaN", "nan", ""]:
181
+ current_produto = produto_cell.replace("\n", " ").strip()
182
+
183
+ if current_produto is None:
184
+ continue
185
+
186
+ if produto and produto.lower() not in current_produto.lower():
187
+ continue
188
+
189
+ safra = str(row.iloc[1]).strip() if pd.notna(row.iloc[1]) else None
190
+ if not safra or "/" not in safra:
191
+ continue
192
+
193
+ suprimento = {
194
+ "produto": current_produto,
195
+ "safra": safra,
196
+ "levantamento": str(row.iloc[2]).strip() if pd.notna(row.iloc[2]) else None,
197
+ "estoque_inicial": self._parse_decimal(row.iloc[3]),
198
+ "producao": self._parse_decimal(row.iloc[4]),
199
+ "importacao": self._parse_decimal(row.iloc[5]),
200
+ "suprimento_total": self._parse_decimal(row.iloc[6]),
201
+ "consumo": self._parse_decimal(row.iloc[7]),
202
+ "exportacao": self._parse_decimal(row.iloc[8]),
203
+ "demanda_total": self._parse_decimal(row.iloc[9]),
204
+ "estoque_final": self._parse_decimal(row.iloc[10]),
205
+ "unidade": "mil_ton",
206
+ }
207
+
208
+ suprimentos.append(suprimento)
209
+
210
+ logger.info(
211
+ "conab_parse_suprimento_success",
212
+ produto=produto,
213
+ records=len(suprimentos),
214
+ )
215
+
216
+ return suprimentos
217
+
218
+ def parse_brasil_total(
219
+ self,
220
+ xlsx: BytesIO,
221
+ safra_ref: str | None = None,
222
+ ) -> list[dict[str, Any]]:
223
+ """
224
+ Extrai dados totais do Brasil por produto.
225
+
226
+ Args:
227
+ xlsx: BytesIO com arquivo XLSX
228
+ safra_ref: Safra de referência (opcional)
229
+
230
+ Returns:
231
+ Lista de dicts com totais por produto
232
+ """
233
+ try:
234
+ df = pd.read_excel(xlsx, sheet_name="Brasil - Total por Produto", header=None)
235
+ except Exception as e:
236
+ raise ParseError(
237
+ source="conab",
238
+ parser_version=self.version,
239
+ reason=f"Erro ao ler aba Brasil - Total por Produto: {e}",
240
+ ) from e
241
+
242
+ totais: list[dict[str, Any]] = []
243
+
244
+ header_row = self._find_header_row(df)
245
+ if header_row is None:
246
+ return totais
247
+
248
+ safra_cols = self._extract_safra_columns(df, header_row)
249
+ data_row = header_row + 3
250
+
251
+ for idx in range(data_row, len(df)):
252
+ row = df.iloc[idx]
253
+ produto = str(row.iloc[0]).strip() if pd.notna(row.iloc[0]) else None
254
+
255
+ if not produto or produto in ["NaN", "nan", "", "TOTAL"]:
256
+ continue
257
+
258
+ for safra_str, cols in safra_cols.items():
259
+ if safra_ref and safra_str != safra_ref:
260
+ continue
261
+
262
+ total = {
263
+ "produto": produto,
264
+ "safra": safra_str,
265
+ "area_plantada": self._parse_decimal(row.iloc[cols["area"]]),
266
+ "produtividade": self._parse_decimal(row.iloc[cols["produtividade"]]),
267
+ "producao": self._parse_decimal(row.iloc[cols["producao"]]),
268
+ "unidade_area": "mil_ha",
269
+ "unidade_producao": "mil_ton",
270
+ }
271
+ totais.append(total)
272
+
273
+ logger.info(
274
+ "conab_parse_brasil_total_success",
275
+ records=len(totais),
276
+ )
277
+
278
+ return totais
279
+
280
+ def _find_header_row(self, df: pd.DataFrame) -> int | None:
281
+ """Encontra a linha do header."""
282
+ for idx, row in df.iterrows():
283
+ cell0 = str(row.iloc[0]).upper() if pd.notna(row.iloc[0]) else ""
284
+ if "REGI" in cell0 or "UF" in cell0 or "PRODUTO" in cell0:
285
+ return cast(int, idx)
286
+ return None
287
+
288
+ def _extract_safra_columns(
289
+ self,
290
+ df: pd.DataFrame,
291
+ header_row: int,
292
+ ) -> dict[str, dict[str, int]]:
293
+ """
294
+ Extrai mapeamento de safras para colunas.
295
+
296
+ Returns:
297
+ Dict com safra -> {area: col, produtividade: col, producao: col}
298
+ """
299
+ safra_row = df.iloc[header_row + 1]
300
+ header_cols = df.iloc[header_row]
301
+ cols = {}
302
+
303
+ area_start = None
304
+ prod_start = None
305
+ producao_start = None
306
+
307
+ for col_idx in range(1, len(header_cols)):
308
+ cell = (
309
+ str(header_cols.iloc[col_idx]).upper()
310
+ if pd.notna(header_cols.iloc[col_idx])
311
+ else ""
312
+ )
313
+ if "ÁREA" in cell or "AREA" in cell:
314
+ area_start = col_idx
315
+ elif "PRODUTIVIDADE" in cell:
316
+ prod_start = col_idx
317
+ elif "PRODUÇÃO" in cell or "PRODUCAO" in cell:
318
+ producao_start = col_idx
319
+
320
+ safras_encontradas = []
321
+ for col_idx in range(1, len(safra_row)):
322
+ cell = str(safra_row.iloc[col_idx]).strip() if pd.notna(safra_row.iloc[col_idx]) else ""
323
+
324
+ if "Safra" in cell or ("/" in cell and "VAR" not in cell.upper()):
325
+ safra_match = cell.replace("Safra ", "").strip()
326
+ if "/" in safra_match:
327
+ parts = safra_match.split("/")
328
+ if len(parts) == 2:
329
+ ano1 = parts[0].strip()
330
+ ano2 = parts[1].strip()
331
+
332
+ if len(ano1) == 2:
333
+ ano1 = "20" + ano1
334
+ if len(ano2) == 2:
335
+ pass
336
+
337
+ safra_full = f"{ano1}/{ano2}"
338
+ if safra_full not in safras_encontradas:
339
+ safras_encontradas.append(safra_full)
340
+
341
+ if area_start and prod_start and producao_start and safras_encontradas:
342
+ for i, safra in enumerate(safras_encontradas):
343
+ cols[safra] = {
344
+ "area": area_start + i,
345
+ "produtividade": prod_start + i,
346
+ "producao": producao_start + i,
347
+ }
348
+ elif safras_encontradas:
349
+ for i, safra in enumerate(safras_encontradas):
350
+ base_col = 1 + (i * 3)
351
+ cols[safra] = {
352
+ "area": base_col,
353
+ "produtividade": base_col + 3 * len(safras_encontradas),
354
+ "producao": base_col + 6 * len(safras_encontradas),
355
+ }
356
+
357
+ if not cols:
358
+ cols["2025/26"] = {
359
+ "area": 2,
360
+ "produtividade": 5,
361
+ "producao": 8,
362
+ }
363
+
364
+ return cols
365
+
366
+ def _parse_decimal(self, value: Any) -> Decimal | None:
367
+ """Converte valor para Decimal."""
368
+ if pd.isna(value):
369
+ return None
370
+
371
+ try:
372
+ if isinstance(value, int | float):
373
+ return Decimal(str(value))
374
+
375
+ value_str = str(value).strip().replace(",", ".")
376
+ value_str = value_str.replace(" ", "")
377
+
378
+ if not value_str or value_str in ["0", "-", "NaN", "nan"]:
379
+ return None
380
+
381
+ return Decimal(value_str)
382
+ except (InvalidOperation, ValueError):
383
+ return None
agrobr/constants.py ADDED
@@ -0,0 +1,205 @@
1
+ """Constantes e configurações do agrobr."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+ from pathlib import Path
7
+
8
+ from pydantic_settings import BaseSettings
9
+
10
+
11
+ class Fonte(StrEnum):
12
+ CEPEA = "cepea"
13
+ CONAB = "conab"
14
+ IBGE = "ibge"
15
+ NOTICIAS_AGRICOLAS = "noticias_agricolas" # Fonte alternativa para CEPEA
16
+
17
+
18
+ URLS = {
19
+ Fonte.CEPEA: {
20
+ "base": "https://www.cepea.org.br",
21
+ "indicadores": "https://www.cepea.org.br/br/indicador",
22
+ },
23
+ Fonte.CONAB: {
24
+ "base": "https://www.gov.br/conab",
25
+ "safras": "https://www.gov.br/conab/pt-br/atuacao/informacoes-agropecuarias/safras",
26
+ "boletim_graos": "https://www.gov.br/conab/pt-br/atuacao/informacoes-agropecuarias/safras/safra-de-graos/boletim-da-safra-de-graos",
27
+ },
28
+ Fonte.IBGE: {
29
+ "base": "https://sidra.ibge.gov.br",
30
+ "api": "https://apisidra.ibge.gov.br",
31
+ },
32
+ Fonte.NOTICIAS_AGRICOLAS: {
33
+ "base": "https://www.noticiasagricolas.com.br",
34
+ "cotacoes": "https://www.noticiasagricolas.com.br/cotacoes",
35
+ },
36
+ }
37
+
38
+ # Mapeamento de produtos para URLs do Notícias Agrícolas (indicadores CEPEA)
39
+ NOTICIAS_AGRICOLAS_PRODUTOS = {
40
+ "soja": "soja/soja-indicador-cepea-esalq-porto-paranagua",
41
+ "soja_parana": "soja/indicador-cepea-esalq-soja-parana",
42
+ "milho": "milho/milho-indicador-cepea-esalq-campinas",
43
+ "boi": "boi/boi-gordo-indicador-cepea-esalq-sao-paulo",
44
+ "boi_gordo": "boi/boi-gordo-indicador-cepea-esalq-sao-paulo",
45
+ "cafe": "cafe/cafe-arabica-indicador-cepea-esalq",
46
+ "cafe_arabica": "cafe/cafe-arabica-indicador-cepea-esalq",
47
+ "algodao": "algodao/algodao-indicador-cepea-esalq",
48
+ "trigo": "trigo/trigo-indicador-cepea-esalq-parana",
49
+ }
50
+
51
+ CEPEA_PRODUTOS = {
52
+ "soja": "soja",
53
+ "milho": "milho",
54
+ "cafe": "cafe",
55
+ "cafe_arabica": "cafe",
56
+ "boi": "boi-gordo",
57
+ "boi_gordo": "boi-gordo",
58
+ "trigo": "trigo",
59
+ "algodao": "algodao",
60
+ "arroz": "arroz",
61
+ "frango": "frango",
62
+ "suino": "suino",
63
+ "acucar": "acucar",
64
+ "etanol": "etanol",
65
+ "etanol_hidratado": "etanol",
66
+ }
67
+
68
+ CONAB_PRODUTOS = {
69
+ "soja": "Soja",
70
+ "milho": "Milho Total",
71
+ "milho_1": "Milho 1a",
72
+ "milho_2": "Milho 2a",
73
+ "milho_3": "Milho 3a",
74
+ "arroz": "Arroz Total",
75
+ "arroz_irrigado": "Arroz Irrigado",
76
+ "arroz_sequeiro": "Arroz Sequeiro",
77
+ "feijao": "Feijão Total",
78
+ "feijao_1": "Feijão 1a Total",
79
+ "feijao_2": "Feijão 2a Total",
80
+ "feijao_3": "Feijão 3a Total",
81
+ "algodao": "Algodao Total",
82
+ "algodao_pluma": "Algodao em Pluma",
83
+ "trigo": "Trigo",
84
+ "sorgo": "Sorgo",
85
+ "aveia": "Aveia",
86
+ "cevada": "Cevada",
87
+ "canola": "Canola",
88
+ "girassol": "Girassol",
89
+ "mamona": "Mamona",
90
+ "amendoim": "Amendoim Total",
91
+ "centeio": "Centeio",
92
+ "triticale": "Triticale",
93
+ "gergelim": "Gergelim",
94
+ }
95
+
96
+ CONAB_UFS = [
97
+ "AC",
98
+ "AL",
99
+ "AM",
100
+ "AP",
101
+ "BA",
102
+ "CE",
103
+ "DF",
104
+ "ES",
105
+ "GO",
106
+ "MA",
107
+ "MG",
108
+ "MS",
109
+ "MT",
110
+ "PA",
111
+ "PB",
112
+ "PE",
113
+ "PI",
114
+ "PR",
115
+ "RJ",
116
+ "RN",
117
+ "RO",
118
+ "RR",
119
+ "RS",
120
+ "SC",
121
+ "SE",
122
+ "SP",
123
+ "TO",
124
+ ]
125
+
126
+ CONAB_REGIOES = ["NORTE", "NORDESTE", "CENTRO-OESTE", "SUDESTE", "SUL"]
127
+
128
+
129
+ class CacheSettings(BaseSettings):
130
+ cache_dir: Path = Path.home() / ".agrobr" / "cache"
131
+ db_name: str = "agrobr.duckdb"
132
+
133
+ ttl_cepea_diario: int = 4 * 3600
134
+ ttl_cepea_semanal: int = 24 * 3600
135
+ ttl_conab: int = 24 * 3600
136
+ ttl_ibge_pam: int = 168 * 3600
137
+ ttl_ibge_lspa: int = 24 * 3600
138
+
139
+ stale_multiplier: float = 12.0
140
+
141
+ offline_mode: bool = False
142
+ strict_mode: bool = False
143
+ save_to_history: bool = True
144
+
145
+ cache_max_age_days: int = 30
146
+ history_max_age_days: int = 0
147
+
148
+ class Config:
149
+ env_prefix = "AGROBR_CACHE_"
150
+
151
+
152
+ class HTTPSettings(BaseSettings):
153
+ timeout_connect: float = 10.0
154
+ timeout_read: float = 30.0
155
+ timeout_write: float = 10.0
156
+ timeout_pool: float = 10.0
157
+
158
+ max_retries: int = 3
159
+ retry_base_delay: float = 1.0
160
+ retry_max_delay: float = 30.0
161
+ retry_exponential_base: int = 2
162
+
163
+ rate_limit_cepea: float = 2.0
164
+ rate_limit_conab: float = 3.0
165
+ rate_limit_ibge: float = 1.0
166
+ rate_limit_noticias_agricolas: float = 2.0
167
+
168
+ class Config:
169
+ env_prefix = "AGROBR_HTTP_"
170
+
171
+
172
+ class AlertSettings(BaseSettings):
173
+ enabled: bool = True
174
+
175
+ slack_webhook: str | None = None
176
+ discord_webhook: str | None = None
177
+
178
+ sendgrid_api_key: str | None = None
179
+ email_from: str = "alerts@agrobr.dev"
180
+ email_to: list[str] = []
181
+
182
+ alert_on_parse_error: bool = True
183
+ alert_on_layout_change: bool = True
184
+ alert_on_source_down: bool = True
185
+ alert_on_anomaly: bool = False
186
+
187
+ class Config:
188
+ env_prefix = "AGROBR_ALERT_"
189
+
190
+
191
+ class TelemetrySettings(BaseSettings):
192
+ enabled: bool = False
193
+ endpoint: str = "https://telemetry.agrobr.dev/v1/events"
194
+ batch_size: int = 10
195
+ flush_interval_seconds: int = 60
196
+
197
+ class Config:
198
+ env_prefix = "AGROBR_TELEMETRY_"
199
+
200
+
201
+ CONFIDENCE_HIGH: float = 0.85
202
+ CONFIDENCE_MEDIUM: float = 0.70
203
+ CONFIDENCE_LOW: float = 0.50
204
+
205
+ RETRIABLE_STATUS_CODES: set[int] = {408, 429, 500, 502, 503, 504}
agrobr/exceptions.py ADDED
@@ -0,0 +1,104 @@
1
+ """Exceções tipadas do agrobr."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class AgrobrError(Exception):
9
+ """Base para todas as exceções do agrobr."""
10
+
11
+ pass
12
+
13
+
14
+ class SourceUnavailableError(AgrobrError):
15
+ """Fonte de dados não disponível após todas as tentativas."""
16
+
17
+ def __init__(self, source: str, url: str, last_error: str) -> None:
18
+ self.source = source
19
+ self.url = url
20
+ self.last_error = last_error
21
+ super().__init__(f"{source} unavailable: {last_error}")
22
+
23
+
24
+ class ParseError(AgrobrError):
25
+ """Falha ao parsear dados da fonte."""
26
+
27
+ def __init__(
28
+ self,
29
+ source: str,
30
+ parser_version: int,
31
+ reason: str,
32
+ html_snippet: str = "",
33
+ ) -> None:
34
+ self.source = source
35
+ self.parser_version = parser_version
36
+ self.reason = reason
37
+ self.html_snippet = html_snippet[:500]
38
+ super().__init__(f"Parse failed ({source} v{parser_version}): {reason}")
39
+
40
+
41
+ class ValidationError(AgrobrError):
42
+ """Dados não passaram validação Pydantic ou estatística."""
43
+
44
+ def __init__(
45
+ self,
46
+ source: str,
47
+ field: str,
48
+ value: Any,
49
+ reason: str,
50
+ ) -> None:
51
+ self.source = source
52
+ self.field = field
53
+ self.value = value
54
+ self.reason = reason
55
+ super().__init__(f"Validation failed: {field}={value} - {reason}")
56
+
57
+
58
+ class CacheError(AgrobrError):
59
+ """Erro de operação de cache."""
60
+
61
+ pass
62
+
63
+
64
+ class FingerprintMismatchError(AgrobrError):
65
+ """Estrutura da página mudou significativamente."""
66
+
67
+ def __init__(self, source: str, similarity: float, threshold: float) -> None:
68
+ self.source = source
69
+ self.similarity = similarity
70
+ self.threshold = threshold
71
+ super().__init__(
72
+ f"Layout change detected in {source}: "
73
+ f"similarity {similarity:.2%} < threshold {threshold:.2%}"
74
+ )
75
+
76
+
77
+ class StaleDataWarning(UserWarning):
78
+ """Dados do cache estão expirados mas foram retornados."""
79
+
80
+ pass
81
+
82
+
83
+ class PartialDataWarning(UserWarning):
84
+ """Dados retornados estão incompletos."""
85
+
86
+ pass
87
+
88
+
89
+ class LayoutChangeWarning(UserWarning):
90
+ """Possível mudança de layout detectada (baixa confiança)."""
91
+
92
+ pass
93
+
94
+
95
+ class AnomalyDetectedWarning(UserWarning):
96
+ """Anomalia estatística detectada nos dados."""
97
+
98
+ pass
99
+
100
+
101
+ class ParserFallbackWarning(UserWarning):
102
+ """Parser principal falhou, usando fallback."""
103
+
104
+ pass