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/__init__.py +3 -2
- agrobr/benchmark/__init__.py +343 -0
- agrobr/cache/policies.py +99 -17
- agrobr/cepea/api.py +87 -30
- agrobr/cepea/client.py +1 -8
- agrobr/cli.py +141 -5
- agrobr/conab/api.py +72 -6
- agrobr/config.py +137 -0
- agrobr/constants.py +1 -2
- agrobr/contracts/__init__.py +186 -0
- agrobr/contracts/cepea.py +80 -0
- agrobr/contracts/conab.py +181 -0
- agrobr/contracts/ibge.py +146 -0
- agrobr/export.py +251 -0
- agrobr/health/__init__.py +10 -0
- agrobr/health/doctor.py +321 -0
- agrobr/http/browser.py +0 -9
- agrobr/ibge/api.py +104 -25
- agrobr/ibge/client.py +5 -20
- agrobr/models.py +100 -1
- agrobr/noticias_agricolas/client.py +0 -7
- agrobr/noticias_agricolas/parser.py +0 -17
- agrobr/plugins/__init__.py +205 -0
- agrobr/quality.py +319 -0
- agrobr/sla.py +249 -0
- agrobr/snapshots.py +321 -0
- agrobr/stability.py +148 -0
- agrobr/validators/semantic.py +447 -0
- {agrobr-0.1.0.dist-info → agrobr-0.5.0.dist-info}/METADATA +12 -12
- {agrobr-0.1.0.dist-info → agrobr-0.5.0.dist-info}/RECORD +33 -19
- {agrobr-0.1.0.dist-info → agrobr-0.5.0.dist-info}/WHEEL +0 -0
- {agrobr-0.1.0.dist-info → agrobr-0.5.0.dist-info}/entry_points.txt +0 -0
- {agrobr-0.1.0.dist-info → agrobr-0.5.0.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|