agrobr 0.1.2__py3-none-any.whl → 0.5.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/cepea/client.py CHANGED
@@ -14,9 +14,7 @@ from agrobr.normalize.encoding import decode_content
14
14
 
15
15
  logger = structlog.get_logger()
16
16
 
17
- # Flag para controlar uso de browser
18
17
  _use_browser: bool = False
19
- # Flag para controlar uso de fonte alternativa (Notícias Agrícolas)
20
18
  _use_alternative_source: bool = True
21
19
 
22
20
 
@@ -141,7 +139,6 @@ async def fetch_indicador_page(
141
139
  Raises:
142
140
  SourceUnavailableError: Se não conseguir acessar após todos os fallbacks
143
141
  """
144
- # Se forçar fonte alternativa, vai direto para Notícias Agrícolas
145
142
  if force_alternative:
146
143
  return await _fetch_with_alternative_source(produto)
147
144
 
@@ -157,7 +154,6 @@ async def fetch_indicador_page(
157
154
 
158
155
  last_error: str = ""
159
156
 
160
- # Passo 1: Tenta httpx (a menos que force_browser)
161
157
  if not force_browser:
162
158
  try:
163
159
  return await _fetch_with_httpx(url, headers)
@@ -170,7 +166,6 @@ async def fetch_indicador_page(
170
166
  error=last_error,
171
167
  )
172
168
 
173
- # Passo 2: Tenta browser (se habilitado)
174
169
  if _use_browser:
175
170
  try:
176
171
  return await _fetch_with_browser(produto)
@@ -183,7 +178,6 @@ async def fetch_indicador_page(
183
178
  error=last_error,
184
179
  )
185
180
 
186
- # Passo 3: Tenta fonte alternativa (se habilitado)
187
181
  if _use_alternative_source:
188
182
  try:
189
183
  return await _fetch_with_alternative_source(produto)
@@ -196,7 +190,6 @@ async def fetch_indicador_page(
196
190
  error=last_error,
197
191
  )
198
192
 
199
- # Todos os métodos falharam
200
193
  logger.error(
201
194
  "all_methods_failed",
202
195
  source="cepea",
agrobr/cli.py CHANGED
@@ -68,6 +68,29 @@ def health(
68
68
  typer.echo(json.dumps(result, indent=2))
69
69
 
70
70
 
71
+ @app.command("doctor") # type: ignore[misc]
72
+ def doctor(
73
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Mostra informacoes detalhadas"),
74
+ json_output: bool = typer.Option(False, "--json", help="Output em formato JSON"),
75
+ ) -> None:
76
+ """Diagnostica saude do sistema, conectividade e cache."""
77
+ import asyncio
78
+
79
+ from agrobr.health.doctor import run_diagnostics
80
+
81
+ try:
82
+ result = asyncio.run(run_diagnostics(verbose=verbose))
83
+
84
+ if json_output:
85
+ typer.echo(json.dumps(result.to_dict(), indent=2, ensure_ascii=False))
86
+ else:
87
+ typer.echo(result.to_rich())
88
+
89
+ except Exception as e:
90
+ typer.echo(f"Erro ao executar diagnostico: {e}", err=True)
91
+ raise typer.Exit(1) from None
92
+
93
+
71
94
  cache_app = typer.Typer(help="Gerenciamento de cache")
72
95
  app.add_typer(cache_app, name="cache")
73
96
 
@@ -191,10 +214,6 @@ def conab_produtos() -> None:
191
214
  typer.echo(f" - {prod}")
192
215
 
193
216
 
194
- # =============================================================================
195
- # IBGE Commands
196
- # =============================================================================
197
-
198
217
  ibge_app = typer.Typer(help="Dados IBGE - PAM e LSPA")
199
218
  app.add_typer(ibge_app, name="ibge")
200
219
 
@@ -217,7 +236,6 @@ def ibge_pam(
217
236
  typer.echo(f"Consultando PAM para {produto}...")
218
237
 
219
238
  try:
220
- # Parse ano
221
239
  ano_param: int | list[int] | None = None
222
240
  if ano:
223
241
  ano_param = [int(a.strip()) for a in ano.split(",")] if "," in ano else int(ano)
@@ -298,6 +316,9 @@ def ibge_produtos(
298
316
  config_app = typer.Typer(help="Configuracoes")
299
317
  app.add_typer(config_app, name="config")
300
318
 
319
+ snapshot_app = typer.Typer(help="Gerenciamento de snapshots para modo deterministico")
320
+ app.add_typer(snapshot_app, name="snapshot")
321
+
301
322
 
302
323
  @config_app.command("show") # type: ignore[misc]
303
324
  def config_show() -> None:
@@ -319,5 +340,120 @@ def config_show() -> None:
319
340
  typer.echo(f" slack_webhook: {'configured' if alerts.slack_webhook else 'not set'}")
320
341
 
321
342
 
343
+ @snapshot_app.command("list") # type: ignore[misc]
344
+ def snapshot_list(
345
+ json_output: bool = typer.Option(False, "--json", help="Output em formato JSON"),
346
+ ) -> None:
347
+ """Lista todos os snapshots disponiveis."""
348
+ from agrobr.snapshots import list_snapshots
349
+
350
+ snapshots = list_snapshots()
351
+
352
+ if not snapshots:
353
+ typer.echo("Nenhum snapshot encontrado.")
354
+ typer.echo("Use 'agrobr snapshot create' para criar um snapshot.")
355
+ return
356
+
357
+ if json_output:
358
+ data = [
359
+ {
360
+ "name": s.name,
361
+ "created_at": s.created_at.isoformat(),
362
+ "size_mb": round(s.size_bytes / 1024 / 1024, 2),
363
+ "sources": s.sources,
364
+ "files": s.file_count,
365
+ }
366
+ for s in snapshots
367
+ ]
368
+ typer.echo(json.dumps(data, indent=2))
369
+ else:
370
+ typer.echo("Snapshots disponiveis:")
371
+ typer.echo("-" * 60)
372
+ for s in snapshots:
373
+ size_mb = s.size_bytes / 1024 / 1024
374
+ typer.echo(f" {s.name}")
375
+ typer.echo(f" Criado em: {s.created_at.strftime('%Y-%m-%d %H:%M')}")
376
+ typer.echo(f" Tamanho: {size_mb:.2f} MB")
377
+ typer.echo(f" Fontes: {', '.join(s.sources)}")
378
+ typer.echo(f" Arquivos: {s.file_count}")
379
+ typer.echo()
380
+
381
+
382
+ @snapshot_app.command("create") # type: ignore[misc]
383
+ def snapshot_create(
384
+ name: str | None = typer.Argument(None, help="Nome do snapshot (default: data atual)"),
385
+ sources: str | None = typer.Option(
386
+ None, "--sources", "-s", help="Fontes a incluir (ex: cepea,conab,ibge)"
387
+ ),
388
+ ) -> None:
389
+ """Cria um novo snapshot dos dados atuais."""
390
+ import asyncio
391
+
392
+ from agrobr.snapshots import create_snapshot
393
+
394
+ source_list = sources.split(",") if sources else None
395
+
396
+ typer.echo(f"Criando snapshot{f' {name}' if name else ''}...")
397
+
398
+ try:
399
+ info = asyncio.run(create_snapshot(name=name, sources=source_list))
400
+ typer.echo("Snapshot criado com sucesso!")
401
+ typer.echo(f" Nome: {info.name}")
402
+ typer.echo(f" Caminho: {info.path}")
403
+ typer.echo(f" Arquivos: {info.file_count}")
404
+ except ValueError as e:
405
+ typer.echo(f"Erro: {e}", err=True)
406
+ raise typer.Exit(1) from None
407
+ except Exception as e:
408
+ typer.echo(f"Erro ao criar snapshot: {e}", err=True)
409
+ raise typer.Exit(1) from None
410
+
411
+
412
+ @snapshot_app.command("delete") # type: ignore[misc]
413
+ def snapshot_delete(
414
+ name: str = typer.Argument(..., help="Nome do snapshot a remover"),
415
+ force: bool = typer.Option(False, "--force", "-f", help="Nao pedir confirmacao"),
416
+ ) -> None:
417
+ """Remove um snapshot."""
418
+ from agrobr.snapshots import delete_snapshot, get_snapshot
419
+
420
+ snapshot = get_snapshot(name)
421
+ if not snapshot:
422
+ typer.echo(f"Snapshot '{name}' nao encontrado.", err=True)
423
+ raise typer.Exit(1)
424
+
425
+ if not force:
426
+ confirm = typer.confirm(f"Remover snapshot '{name}'?")
427
+ if not confirm:
428
+ typer.echo("Operacao cancelada.")
429
+ return
430
+
431
+ if delete_snapshot(name):
432
+ typer.echo(f"Snapshot '{name}' removido com sucesso.")
433
+ else:
434
+ typer.echo("Erro ao remover snapshot.", err=True)
435
+ raise typer.Exit(1)
436
+
437
+
438
+ @snapshot_app.command("use") # type: ignore[misc]
439
+ def snapshot_use(
440
+ name: str = typer.Argument(..., help="Nome do snapshot a usar"),
441
+ ) -> None:
442
+ """Configura agrobr para usar um snapshot (modo deterministico)."""
443
+ from agrobr.config import set_mode
444
+ from agrobr.snapshots import get_snapshot
445
+
446
+ snapshot = get_snapshot(name)
447
+ if not snapshot:
448
+ typer.echo(f"Snapshot '{name}' nao encontrado.", err=True)
449
+ typer.echo("Use 'agrobr snapshot list' para ver snapshots disponiveis.")
450
+ raise typer.Exit(1)
451
+
452
+ set_mode("deterministic", snapshot=name)
453
+ typer.echo(f"Modo deterministico ativado com snapshot '{name}'.")
454
+ typer.echo("Todas as chamadas usarao dados do snapshot.")
455
+ typer.echo("Use 'agrobr config mode normal' para voltar ao modo normal.")
456
+
457
+
322
458
  if __name__ == "__main__":
323
459
  app()
agrobr/conab/api.py CHANGED
@@ -2,25 +2,54 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any
5
+ import time
6
+ from datetime import datetime
7
+ from typing import Any, Literal, overload
6
8
 
7
9
  import pandas as pd
8
10
  import structlog
9
11
 
10
12
  from agrobr import constants
13
+ from agrobr.cache.policies import calculate_expiry
11
14
  from agrobr.conab import client
12
15
  from agrobr.conab.parsers.v1 import ConabParserV1
16
+ from agrobr.models import MetaInfo
13
17
 
14
18
  logger = structlog.get_logger()
15
19
 
16
20
 
21
+ @overload
17
22
  async def safras(
18
23
  produto: str,
19
24
  safra: str | None = None,
20
25
  uf: str | None = None,
21
26
  levantamento: int | None = None,
22
27
  as_polars: bool = False,
23
- ) -> pd.DataFrame:
28
+ *,
29
+ return_meta: Literal[False] = False,
30
+ ) -> pd.DataFrame: ...
31
+
32
+
33
+ @overload
34
+ async def safras(
35
+ produto: str,
36
+ safra: str | None = None,
37
+ uf: str | None = None,
38
+ levantamento: int | None = None,
39
+ as_polars: bool = False,
40
+ *,
41
+ return_meta: Literal[True],
42
+ ) -> tuple[pd.DataFrame, MetaInfo]: ...
43
+
44
+
45
+ async def safras(
46
+ produto: str,
47
+ safra: str | None = None,
48
+ uf: str | None = None,
49
+ levantamento: int | None = None,
50
+ as_polars: bool = False,
51
+ return_meta: bool = False,
52
+ ) -> pd.DataFrame | tuple[pd.DataFrame, MetaInfo]:
24
53
  """
25
54
  Obtém dados de safra por produto.
26
55
 
@@ -30,14 +59,23 @@ async def safras(
30
59
  uf: Filtrar por UF (ex: "MT", "PR")
31
60
  levantamento: Número do levantamento (default: mais recente)
32
61
  as_polars: Se True, retorna polars.DataFrame
62
+ return_meta: Se True, retorna tupla (DataFrame, MetaInfo)
33
63
 
34
64
  Returns:
35
- DataFrame com dados de safra por UF
65
+ DataFrame com dados de safra por UF ou tupla (DataFrame, MetaInfo)
36
66
 
37
67
  Example:
38
68
  >>> df = await conab.safras('soja', safra='2025/26')
39
- >>> df = await conab.safras('milho', uf='MT')
69
+ >>> df, meta = await conab.safras('milho', uf='MT', return_meta=True)
40
70
  """
71
+ fetch_start = time.perf_counter()
72
+ meta = MetaInfo(
73
+ source="conab",
74
+ source_url="https://www.conab.gov.br/info-agro/safras/graos",
75
+ source_method="httpx",
76
+ fetched_at=datetime.now(),
77
+ )
78
+
41
79
  logger.info(
42
80
  "conab_safras_request",
43
81
  produto=produto,
@@ -46,8 +84,17 @@ async def safras(
46
84
  levantamento=levantamento,
47
85
  )
48
86
 
87
+ parse_start = time.perf_counter()
49
88
  xlsx, metadata = await client.fetch_safra_xlsx(safra=safra, levantamento=levantamento)
50
89
 
90
+ if isinstance(xlsx, bytes):
91
+ meta.raw_content_size = len(xlsx)
92
+ elif hasattr(xlsx, "getbuffer"):
93
+ meta.raw_content_size = len(xlsx.getbuffer())
94
+ else:
95
+ meta.raw_content_size = 0
96
+ meta.source_url = metadata.get("url", meta.source_url)
97
+
51
98
  parser = ConabParserV1()
52
99
  safra_list = parser.parse_safra_produto(
53
100
  xlsx=xlsx,
@@ -56,6 +103,9 @@ async def safras(
56
103
  levantamento=metadata.get("levantamento"),
57
104
  )
58
105
 
106
+ meta.parse_duration_ms = int((time.perf_counter() - parse_start) * 1000)
107
+ meta.parser_version = parser.version
108
+
59
109
  if uf:
60
110
  safra_list = [s for s in safra_list if s.uf == uf.upper()]
61
111
 
@@ -66,15 +116,29 @@ async def safras(
66
116
  safra=safra,
67
117
  uf=uf,
68
118
  )
69
- return pd.DataFrame()
119
+ df = pd.DataFrame()
120
+ if return_meta:
121
+ meta.records_count = 0
122
+ meta.fetch_duration_ms = int((time.perf_counter() - fetch_start) * 1000)
123
+ return df, meta
124
+ return df
70
125
 
71
126
  df = pd.DataFrame([s.model_dump() for s in safra_list])
72
127
 
128
+ meta.fetch_duration_ms = int((time.perf_counter() - fetch_start) * 1000)
129
+ meta.records_count = len(df)
130
+ meta.columns = df.columns.tolist()
131
+ meta.cache_key = f"conab:safras:{produto}:{safra or 'latest'}"
132
+ meta.cache_expires_at = calculate_expiry(constants.Fonte.CONAB)
133
+
73
134
  if as_polars:
74
135
  try:
75
136
  import polars as pl
76
137
 
77
- return pl.from_pandas(df) # type: ignore[no-any-return]
138
+ result_df = pl.from_pandas(df)
139
+ if return_meta:
140
+ return result_df, meta # type: ignore[return-value,no-any-return]
141
+ return result_df # type: ignore[return-value,no-any-return]
78
142
  except ImportError:
79
143
  logger.warning("polars_not_installed", fallback="pandas")
80
144
 
@@ -84,6 +148,8 @@ async def safras(
84
148
  records=len(df),
85
149
  )
86
150
 
151
+ if return_meta:
152
+ return df, meta
87
153
  return df
88
154
 
89
155
 
agrobr/config.py ADDED
@@ -0,0 +1,137 @@
1
+ """Configuracao global do agrobr."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ _config: AgrobrConfig | None = None
11
+
12
+
13
+ @dataclass
14
+ class AgrobrConfig:
15
+ """Configuracao global do agrobr."""
16
+
17
+ mode: Literal["normal", "deterministic"] = "normal"
18
+ snapshot_date: date | None = None
19
+ snapshot_path: Path | None = None
20
+
21
+ cache_enabled: bool = True
22
+ cache_path: Path | None = None
23
+
24
+ network_enabled: bool = True
25
+ timeout_seconds: int = 30
26
+
27
+ browser_fallback: bool = False
28
+ alternative_source: bool = True
29
+
30
+ log_level: str = "INFO"
31
+
32
+ def is_deterministic(self) -> bool:
33
+ """Verifica se esta em modo deterministico."""
34
+ return self.mode == "deterministic"
35
+
36
+ def get_snapshot_dir(self) -> Path:
37
+ """Retorna diretorio de snapshots."""
38
+ if self.snapshot_path:
39
+ return self.snapshot_path
40
+ return Path.home() / ".agrobr" / "snapshots"
41
+
42
+ def get_current_snapshot_path(self) -> Path | None:
43
+ """Retorna caminho do snapshot atual."""
44
+ if not self.snapshot_date:
45
+ return None
46
+ return self.get_snapshot_dir() / self.snapshot_date.isoformat()
47
+
48
+
49
+ def set_mode(
50
+ mode: Literal["normal", "deterministic"],
51
+ snapshot: str | date | None = None,
52
+ snapshot_path: str | Path | None = None,
53
+ ) -> None:
54
+ """
55
+ Define modo de operacao do agrobr.
56
+
57
+ Args:
58
+ mode: "normal" ou "deterministic"
59
+ snapshot: Data do snapshot (YYYY-MM-DD ou date)
60
+ snapshot_path: Caminho customizado para snapshots
61
+
62
+ Example:
63
+ agrobr.set_mode("normal")
64
+ agrobr.set_mode("deterministic", snapshot="2025-12-31")
65
+ """
66
+ global _config
67
+
68
+ if isinstance(snapshot, str):
69
+ snapshot = date.fromisoformat(snapshot)
70
+
71
+ if isinstance(snapshot_path, str):
72
+ snapshot_path = Path(snapshot_path)
73
+
74
+ _config = AgrobrConfig(
75
+ mode=mode,
76
+ snapshot_date=snapshot,
77
+ snapshot_path=snapshot_path,
78
+ network_enabled=(mode == "normal"),
79
+ )
80
+
81
+
82
+ def get_config() -> AgrobrConfig:
83
+ """Retorna configuracao atual."""
84
+ global _config
85
+ if _config is None:
86
+ _config = AgrobrConfig()
87
+ return _config
88
+
89
+
90
+ def reset_config() -> None:
91
+ """Reseta para configuracao padrao."""
92
+ global _config
93
+ _config = None
94
+
95
+
96
+ def configure(
97
+ cache_enabled: bool | None = None,
98
+ cache_path: str | Path | None = None,
99
+ timeout_seconds: int | None = None,
100
+ browser_fallback: bool | None = None,
101
+ alternative_source: bool | None = None,
102
+ log_level: str | None = None,
103
+ ) -> None:
104
+ """
105
+ Configura opcoes do agrobr.
106
+
107
+ Args:
108
+ cache_enabled: Habilitar/desabilitar cache
109
+ cache_path: Caminho customizado para cache
110
+ timeout_seconds: Timeout para requisicoes HTTP
111
+ browser_fallback: Usar browser como fallback
112
+ alternative_source: Usar fontes alternativas
113
+ log_level: Nivel de log (DEBUG, INFO, WARNING, ERROR)
114
+ """
115
+ config = get_config()
116
+
117
+ if cache_enabled is not None:
118
+ config.cache_enabled = cache_enabled
119
+ if cache_path is not None:
120
+ config.cache_path = Path(cache_path) if isinstance(cache_path, str) else cache_path
121
+ if timeout_seconds is not None:
122
+ config.timeout_seconds = timeout_seconds
123
+ if browser_fallback is not None:
124
+ config.browser_fallback = browser_fallback
125
+ if alternative_source is not None:
126
+ config.alternative_source = alternative_source
127
+ if log_level is not None:
128
+ config.log_level = log_level
129
+
130
+
131
+ __all__ = [
132
+ "AgrobrConfig",
133
+ "set_mode",
134
+ "get_config",
135
+ "reset_config",
136
+ "configure",
137
+ ]
agrobr/constants.py CHANGED
@@ -12,7 +12,7 @@ class Fonte(StrEnum):
12
12
  CEPEA = "cepea"
13
13
  CONAB = "conab"
14
14
  IBGE = "ibge"
15
- NOTICIAS_AGRICOLAS = "noticias_agricolas" # Fonte alternativa para CEPEA
15
+ NOTICIAS_AGRICOLAS = "noticias_agricolas"
16
16
 
17
17
 
18
18
  URLS = {
@@ -35,7 +35,6 @@ URLS = {
35
35
  },
36
36
  }
37
37
 
38
- # Mapeamento de produtos para URLs do Notícias Agrícolas (indicadores CEPEA)
39
38
  NOTICIAS_AGRICOLAS_PRODUTOS = {
40
39
  "soja": "soja/soja-indicador-cepea-esalq-porto-paranagua",
41
40
  "soja_parana": "soja/indicador-cepea-esalq-soja-parana",