agrobr 0.1.0__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/api.py CHANGED
@@ -2,18 +2,21 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
6
+ import time
5
7
  from datetime import date, datetime, timedelta
6
8
  from decimal import Decimal
7
- from typing import TYPE_CHECKING, Any
9
+ from typing import TYPE_CHECKING, Any, Literal, overload
8
10
 
9
11
  import pandas as pd
10
12
  import structlog
11
13
 
12
14
  from agrobr import constants
13
15
  from agrobr.cache.duckdb_store import get_store
16
+ from agrobr.cache.policies import calculate_expiry
14
17
  from agrobr.cepea import client
15
18
  from agrobr.cepea.parsers.detector import get_parser_with_fallback
16
- from agrobr.models import Indicador
19
+ from agrobr.models import Indicador, MetaInfo
17
20
  from agrobr.validators.sanity import validate_batch
18
21
 
19
22
  if TYPE_CHECKING:
@@ -21,10 +24,10 @@ if TYPE_CHECKING:
21
24
 
22
25
  logger = structlog.get_logger()
23
26
 
24
- # Janela de dados disponível na fonte (Notícias Agrícolas tem ~10 dias)
25
27
  SOURCE_WINDOW_DAYS = 10
26
28
 
27
29
 
30
+ @overload
28
31
  async def indicador(
29
32
  produto: str,
30
33
  praca: str | None = None,
@@ -35,7 +38,39 @@ async def indicador(
35
38
  validate_sanity: bool = False,
36
39
  force_refresh: bool = False,
37
40
  offline: bool = False,
38
- ) -> pd.DataFrame | pl.DataFrame:
41
+ *,
42
+ return_meta: Literal[False] = False,
43
+ ) -> pd.DataFrame | pl.DataFrame: ...
44
+
45
+
46
+ @overload
47
+ async def indicador(
48
+ produto: str,
49
+ praca: str | None = None,
50
+ inicio: str | date | None = None,
51
+ fim: str | date | None = None,
52
+ _moeda: str = "BRL",
53
+ as_polars: bool = False,
54
+ validate_sanity: bool = False,
55
+ force_refresh: bool = False,
56
+ offline: bool = False,
57
+ *,
58
+ return_meta: Literal[True],
59
+ ) -> tuple[pd.DataFrame | pl.DataFrame, MetaInfo]: ...
60
+
61
+
62
+ async def indicador(
63
+ produto: str,
64
+ praca: str | None = None,
65
+ inicio: str | date | None = None,
66
+ fim: str | date | None = None,
67
+ _moeda: str = "BRL",
68
+ as_polars: bool = False,
69
+ validate_sanity: bool = False,
70
+ force_refresh: bool = False,
71
+ offline: bool = False,
72
+ return_meta: bool = False,
73
+ ) -> pd.DataFrame | pl.DataFrame | tuple[pd.DataFrame | pl.DataFrame, MetaInfo]:
39
74
  """
40
75
  Obtém série de indicadores de preço do CEPEA.
41
76
 
@@ -55,17 +90,23 @@ async def indicador(
55
90
  validate_sanity: Se True, valida dados estatisticamente
56
91
  force_refresh: Se True, ignora histórico e busca da fonte
57
92
  offline: Se True, usa apenas histórico local
93
+ return_meta: Se True, retorna tupla (DataFrame, MetaInfo)
58
94
 
59
95
  Returns:
60
- DataFrame com indicadores
96
+ DataFrame com indicadores ou tupla (DataFrame, MetaInfo) se return_meta=True
61
97
  """
62
- # Normaliza datas
98
+ fetch_start = time.perf_counter()
99
+ meta = MetaInfo(
100
+ source="unknown",
101
+ source_url="",
102
+ source_method="unknown",
103
+ fetched_at=datetime.now(),
104
+ )
63
105
  if isinstance(inicio, str):
64
106
  inicio = datetime.strptime(inicio, "%Y-%m-%d").date()
65
107
  if isinstance(fim, str):
66
108
  fim = datetime.strptime(fim, "%Y-%m-%d").date()
67
109
 
68
- # Default: último ano
69
110
  if fim is None:
70
111
  fim = date.today()
71
112
  if inicio is None:
@@ -74,7 +115,9 @@ async def indicador(
74
115
  store = get_store()
75
116
  indicadores: list[Indicador] = []
76
117
 
77
- # 1. Busca no histórico local
118
+ source_url = ""
119
+ parser_version = 1
120
+
78
121
  if not force_refresh:
79
122
  cached_data = store.indicadores_query(
80
123
  produto=produto,
@@ -85,6 +128,11 @@ async def indicador(
85
128
 
86
129
  indicadores = _dicts_to_indicadores(cached_data)
87
130
 
131
+ if indicadores:
132
+ meta.from_cache = True
133
+ meta.source = "cache"
134
+ meta.source_method = "duckdb"
135
+
88
136
  logger.info(
89
137
  "history_query",
90
138
  produto=produto,
@@ -93,54 +141,61 @@ async def indicador(
93
141
  cached_count=len(indicadores),
94
142
  )
95
143
 
96
- # 2. Verifica se precisa buscar dados recentes
97
144
  needs_fetch = False
98
145
  if not offline:
99
146
  if force_refresh:
100
147
  needs_fetch = True
101
148
  else:
102
- # Verifica se faltam dados na janela recente
103
149
  today = date.today()
104
150
  recent_start = today - timedelta(days=SOURCE_WINDOW_DAYS)
105
151
 
106
- # Se o período solicitado inclui dados recentes
107
152
  if fim >= recent_start:
108
153
  existing_dates = {ind.data for ind in indicadores}
109
- # Verifica se tem lacunas nos últimos dias
110
154
  for i in range(min(SOURCE_WINDOW_DAYS, (fim - max(inicio, recent_start)).days + 1)):
111
155
  check_date = fim - timedelta(days=i)
112
- # Pula fins de semana (CEPEA não publica)
113
156
  if check_date.weekday() < 5 and check_date not in existing_dates:
114
157
  needs_fetch = True
115
158
  break
116
159
 
117
- # 3. Busca da fonte se necessário
118
160
  if needs_fetch:
119
161
  logger.info("fetching_from_source", produto=produto)
120
162
 
121
163
  try:
122
- # Tenta buscar - pode vir do CEPEA ou Notícias Agrícolas
164
+ parse_start = time.perf_counter()
123
165
  html = await client.fetch_indicador_page(produto)
166
+ raw_content_size = len(html.encode("utf-8"))
167
+ raw_content_hash = f"sha256:{hashlib.sha256(html.encode('utf-8')).hexdigest()[:16]}"
124
168
 
125
- # Detecta fonte pelo conteúdo do HTML
126
169
  is_noticias_agricolas = "noticiasagricolas" in html.lower() or "cot-fisicas" in html
127
170
 
128
171
  if is_noticias_agricolas:
129
- # Usa parser de Notícias Agrícolas
130
172
  from agrobr.noticias_agricolas.parser import parse_indicador as na_parse
131
173
 
132
174
  new_indicadores = na_parse(html, produto)
175
+ source_url = f"https://www.noticiasagricolas.com.br/cotacoes/{produto}"
176
+ meta.source = "noticias_agricolas"
177
+ meta.source_method = "httpx"
133
178
  logger.info(
134
179
  "parse_success",
135
180
  source="noticias_agricolas",
136
181
  records_count=len(new_indicadores),
137
182
  )
138
183
  else:
139
- # Usa parser CEPEA
140
184
  parser, new_indicadores = await get_parser_with_fallback(html, produto)
185
+ source_url = f"https://www.cepea.esalq.usp.br/br/indicador/{produto}.aspx"
186
+ meta.source = "cepea"
187
+ meta.source_method = "httpx"
188
+ parser_version = parser.version
189
+
190
+ parse_duration_ms = int((time.perf_counter() - parse_start) * 1000)
191
+ meta.parse_duration_ms = parse_duration_ms
192
+ meta.source_url = source_url
193
+ meta.raw_content_hash = raw_content_hash
194
+ meta.raw_content_size = raw_content_size
195
+ meta.parser_version = parser_version
196
+ meta.from_cache = False
141
197
 
142
198
  if new_indicadores:
143
- # 4. Salva novos dados no histórico
144
199
  new_dicts = _indicadores_to_dicts(new_indicadores)
145
200
  saved_count = store.indicadores_upsert(new_dicts)
146
201
 
@@ -151,7 +206,6 @@ async def indicador(
151
206
  saved=saved_count,
152
207
  )
153
208
 
154
- # Merge com dados existentes
155
209
  existing_dates = {ind.data for ind in indicadores}
156
210
  for ind in new_indicadores:
157
211
  if ind.data not in existing_dates:
@@ -163,13 +217,11 @@ async def indicador(
163
217
  produto=produto,
164
218
  error=str(e),
165
219
  )
166
- # Continua com dados do histórico
220
+ meta.validation_warnings.append(f"source_fetch_failed: {e}")
167
221
 
168
- # 5. Validação estatística
169
222
  if validate_sanity and indicadores:
170
223
  indicadores, anomalies = await validate_batch(indicadores)
171
224
 
172
- # 6. Filtra por período e praça
173
225
  indicadores = [ind for ind in indicadores if inicio <= ind.data <= fim]
174
226
 
175
227
  if praca:
@@ -177,17 +229,27 @@ async def indicador(
177
229
  ind for ind in indicadores if ind.praca and ind.praca.lower() == praca.lower()
178
230
  ]
179
231
 
180
- # 7. Converte para DataFrame
181
232
  df = _to_dataframe(indicadores)
182
233
 
234
+ meta.fetch_duration_ms = int((time.perf_counter() - fetch_start) * 1000)
235
+ meta.records_count = len(df)
236
+ meta.columns = df.columns.tolist() if not df.empty else []
237
+ meta.cache_key = f"cepea:{produto}:{praca or 'all'}"
238
+ meta.cache_expires_at = calculate_expiry(constants.Fonte.CEPEA)
239
+
183
240
  if as_polars:
184
241
  try:
185
242
  import polars as pl
186
243
 
187
- return pl.from_pandas(df)
244
+ result_df = pl.from_pandas(df)
245
+ if return_meta:
246
+ return result_df, meta
247
+ return result_df
188
248
  except ImportError:
189
249
  logger.warning("polars_not_installed", fallback="pandas")
190
250
 
251
+ if return_meta:
252
+ return df, meta
191
253
  return df
192
254
 
193
255
 
@@ -270,7 +332,6 @@ async def ultimo(produto: str, praca: str | None = None, offline: bool = False)
270
332
  store = get_store()
271
333
  indicadores: list[Indicador] = []
272
334
 
273
- # Busca no histórico (últimos 30 dias)
274
335
  fim = date.today()
275
336
  inicio = fim - timedelta(days=30)
276
337
 
@@ -284,7 +345,6 @@ async def ultimo(produto: str, praca: str | None = None, offline: bool = False)
284
345
  if cached_data:
285
346
  indicadores = _dicts_to_indicadores(cached_data)
286
347
 
287
- # Se não tem dados recentes ou não está offline, busca da fonte
288
348
  if not offline:
289
349
  has_recent = any(ind.data >= fim - timedelta(days=3) for ind in indicadores)
290
350
 
@@ -292,7 +352,6 @@ async def ultimo(produto: str, praca: str | None = None, offline: bool = False)
292
352
  try:
293
353
  html = await client.fetch_indicador_page(produto)
294
354
 
295
- # Detecta fonte pelo conteúdo do HTML
296
355
  is_noticias_agricolas = "noticiasagricolas" in html.lower() or "cot-fisicas" in html
297
356
 
298
357
  if is_noticias_agricolas:
@@ -303,11 +362,9 @@ async def ultimo(produto: str, praca: str | None = None, offline: bool = False)
303
362
  parser, new_indicadores = await get_parser_with_fallback(html, produto)
304
363
 
305
364
  if new_indicadores:
306
- # Salva no histórico
307
365
  new_dicts = _indicadores_to_dicts(new_indicadores)
308
366
  store.indicadores_upsert(new_dicts)
309
367
 
310
- # Merge
311
368
  existing_dates = {ind.data for ind in indicadores}
312
369
  for ind in new_indicadores:
313
370
  if ind.data not in existing_dates:
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
- _use_browser: bool = True
19
- # Flag para controlar uso de fonte alternativa (Notícias Agrícolas)
17
+ _use_browser: bool = False
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