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.
- agrobr/__init__.py +10 -0
- agrobr/alerts/__init__.py +7 -0
- agrobr/alerts/notifier.py +167 -0
- agrobr/cache/__init__.py +31 -0
- agrobr/cache/duckdb_store.py +433 -0
- agrobr/cache/history.py +317 -0
- agrobr/cache/migrations.py +82 -0
- agrobr/cache/policies.py +240 -0
- agrobr/cepea/__init__.py +7 -0
- agrobr/cepea/api.py +360 -0
- agrobr/cepea/client.py +273 -0
- agrobr/cepea/parsers/__init__.py +37 -0
- agrobr/cepea/parsers/base.py +35 -0
- agrobr/cepea/parsers/consensus.py +300 -0
- agrobr/cepea/parsers/detector.py +108 -0
- agrobr/cepea/parsers/fingerprint.py +226 -0
- agrobr/cepea/parsers/v1.py +305 -0
- agrobr/cli.py +323 -0
- agrobr/conab/__init__.py +21 -0
- agrobr/conab/api.py +239 -0
- agrobr/conab/client.py +219 -0
- agrobr/conab/parsers/__init__.py +7 -0
- agrobr/conab/parsers/v1.py +383 -0
- agrobr/constants.py +205 -0
- agrobr/exceptions.py +104 -0
- agrobr/health/__init__.py +23 -0
- agrobr/health/checker.py +202 -0
- agrobr/health/reporter.py +314 -0
- agrobr/http/__init__.py +9 -0
- agrobr/http/browser.py +214 -0
- agrobr/http/rate_limiter.py +69 -0
- agrobr/http/retry.py +93 -0
- agrobr/http/user_agents.py +67 -0
- agrobr/ibge/__init__.py +19 -0
- agrobr/ibge/api.py +273 -0
- agrobr/ibge/client.py +256 -0
- agrobr/models.py +85 -0
- agrobr/normalize/__init__.py +64 -0
- agrobr/normalize/dates.py +303 -0
- agrobr/normalize/encoding.py +102 -0
- agrobr/normalize/regions.py +308 -0
- agrobr/normalize/units.py +278 -0
- agrobr/noticias_agricolas/__init__.py +6 -0
- agrobr/noticias_agricolas/client.py +222 -0
- agrobr/noticias_agricolas/parser.py +187 -0
- agrobr/sync.py +147 -0
- agrobr/telemetry/__init__.py +17 -0
- agrobr/telemetry/collector.py +153 -0
- agrobr/utils/__init__.py +5 -0
- agrobr/utils/logging.py +59 -0
- agrobr/validators/__init__.py +35 -0
- agrobr/validators/sanity.py +286 -0
- agrobr/validators/structural.py +313 -0
- agrobr-0.1.0.dist-info/METADATA +243 -0
- agrobr-0.1.0.dist-info/RECORD +58 -0
- agrobr-0.1.0.dist-info/WHEEL +4 -0
- agrobr-0.1.0.dist-info/entry_points.txt +2 -0
- agrobr-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Parser v1 para indicadores CEPEA - Layout 2024."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from decimal import Decimal, InvalidOperation
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
from bs4 import BeautifulSoup
|
|
12
|
+
|
|
13
|
+
from agrobr.constants import Fonte
|
|
14
|
+
from agrobr.exceptions import ParseError
|
|
15
|
+
from agrobr.models import Indicador
|
|
16
|
+
|
|
17
|
+
from .base import BaseParser
|
|
18
|
+
from .fingerprint import extract_fingerprint
|
|
19
|
+
|
|
20
|
+
logger = structlog.get_logger()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CepeaParserV1(BaseParser):
|
|
24
|
+
"""Parser para layout CEPEA 2024."""
|
|
25
|
+
|
|
26
|
+
version = 1
|
|
27
|
+
source = "cepea"
|
|
28
|
+
valid_from = date(2024, 1, 1)
|
|
29
|
+
valid_until = None
|
|
30
|
+
|
|
31
|
+
def can_parse(self, html: str) -> tuple[bool, float]:
|
|
32
|
+
"""Verifica se este parser consegue processar o HTML."""
|
|
33
|
+
soup = BeautifulSoup(html, "lxml")
|
|
34
|
+
|
|
35
|
+
confidence = 0.0
|
|
36
|
+
checks_passed = 0
|
|
37
|
+
total_checks = 5
|
|
38
|
+
|
|
39
|
+
tables = soup.find_all("table")
|
|
40
|
+
if tables:
|
|
41
|
+
checks_passed += 1
|
|
42
|
+
|
|
43
|
+
indicador_table = soup.find("table", id=re.compile(r"indicador|preco|cotacao", re.I))
|
|
44
|
+
if not indicador_table:
|
|
45
|
+
indicador_table = soup.find(
|
|
46
|
+
"table", class_=re.compile(r"indicador|preco|cotacao", re.I)
|
|
47
|
+
)
|
|
48
|
+
if indicador_table:
|
|
49
|
+
checks_passed += 1
|
|
50
|
+
|
|
51
|
+
headers = soup.find_all("th")
|
|
52
|
+
header_texts = [th.get_text(strip=True).lower() for th in headers]
|
|
53
|
+
date_keywords = ["data", "dia", "date"]
|
|
54
|
+
value_keywords = ["valor", "preço", "preco", "price", "r$"]
|
|
55
|
+
|
|
56
|
+
if any(kw in " ".join(header_texts) for kw in date_keywords):
|
|
57
|
+
checks_passed += 1
|
|
58
|
+
if any(kw in " ".join(header_texts) for kw in value_keywords):
|
|
59
|
+
checks_passed += 1
|
|
60
|
+
|
|
61
|
+
cepea_indicators = soup.find_all(string=re.compile(r"cepea|esalq|indicador", re.I))
|
|
62
|
+
if cepea_indicators:
|
|
63
|
+
checks_passed += 1
|
|
64
|
+
|
|
65
|
+
confidence = checks_passed / total_checks
|
|
66
|
+
|
|
67
|
+
can_parse = confidence >= 0.4
|
|
68
|
+
logger.debug(
|
|
69
|
+
"can_parse_check",
|
|
70
|
+
parser_version=self.version,
|
|
71
|
+
confidence=confidence,
|
|
72
|
+
checks_passed=checks_passed,
|
|
73
|
+
total_checks=total_checks,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return can_parse, confidence
|
|
77
|
+
|
|
78
|
+
def parse(self, html: str, produto: str) -> list[Indicador]:
|
|
79
|
+
"""Parseia HTML e retorna lista de indicadores."""
|
|
80
|
+
soup = BeautifulSoup(html, "lxml")
|
|
81
|
+
indicadores: list[Indicador] = []
|
|
82
|
+
|
|
83
|
+
tables = soup.find_all("table")
|
|
84
|
+
if not tables:
|
|
85
|
+
raise ParseError(
|
|
86
|
+
source=self.source,
|
|
87
|
+
parser_version=self.version,
|
|
88
|
+
reason="No tables found in HTML",
|
|
89
|
+
html_snippet=html[:500],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
data_table = self._find_data_table(soup)
|
|
93
|
+
if not data_table:
|
|
94
|
+
raise ParseError(
|
|
95
|
+
source=self.source,
|
|
96
|
+
parser_version=self.version,
|
|
97
|
+
reason="Could not identify data table",
|
|
98
|
+
html_snippet=html[:500],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
headers = self._extract_headers(data_table)
|
|
102
|
+
rows = data_table.find_all("tr")[1:]
|
|
103
|
+
|
|
104
|
+
for row in rows:
|
|
105
|
+
cells = row.find_all(["td", "th"])
|
|
106
|
+
if len(cells) < 2:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
indicador = self._parse_row(cells, headers, produto)
|
|
111
|
+
if indicador:
|
|
112
|
+
indicadores.append(indicador)
|
|
113
|
+
except (ValueError, InvalidOperation) as e:
|
|
114
|
+
logger.debug(
|
|
115
|
+
"row_parse_failed",
|
|
116
|
+
error=str(e),
|
|
117
|
+
cells=[c.get_text(strip=True) for c in cells],
|
|
118
|
+
)
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
if not indicadores:
|
|
122
|
+
raise ParseError(
|
|
123
|
+
source=self.source,
|
|
124
|
+
parser_version=self.version,
|
|
125
|
+
reason="No valid indicators extracted",
|
|
126
|
+
html_snippet=html[:500],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
logger.info(
|
|
130
|
+
"parse_success",
|
|
131
|
+
source=self.source,
|
|
132
|
+
parser_version=self.version,
|
|
133
|
+
records_count=len(indicadores),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return indicadores
|
|
137
|
+
|
|
138
|
+
def extract_fingerprint(self, html: str) -> dict[str, Any]:
|
|
139
|
+
"""Extrai assinatura estrutural do HTML."""
|
|
140
|
+
fp = extract_fingerprint(html, Fonte.CEPEA, "internal")
|
|
141
|
+
return fp.model_dump()
|
|
142
|
+
|
|
143
|
+
def _find_data_table(self, soup: BeautifulSoup) -> Any | None:
|
|
144
|
+
"""Encontra a tabela com dados de indicadores."""
|
|
145
|
+
table = soup.find("table", id=re.compile(r"indicador|preco|cotacao|dados", re.I))
|
|
146
|
+
if table:
|
|
147
|
+
return table
|
|
148
|
+
|
|
149
|
+
table = soup.find("table", class_=re.compile(r"indicador|preco|cotacao|dados|table", re.I))
|
|
150
|
+
if table:
|
|
151
|
+
return table
|
|
152
|
+
|
|
153
|
+
tables = soup.find_all("table")
|
|
154
|
+
for table in tables:
|
|
155
|
+
headers = table.find_all("th")
|
|
156
|
+
header_text = " ".join(th.get_text(strip=True).lower() for th in headers)
|
|
157
|
+
if "data" in header_text and ("valor" in header_text or "r$" in header_text):
|
|
158
|
+
return table
|
|
159
|
+
|
|
160
|
+
if tables:
|
|
161
|
+
largest_table = max(tables, key=lambda t: len(t.find_all("tr")))
|
|
162
|
+
if len(largest_table.find_all("tr")) >= 3:
|
|
163
|
+
return largest_table
|
|
164
|
+
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
def _extract_headers(self, table: Any) -> list[str]:
|
|
168
|
+
"""Extrai headers da tabela."""
|
|
169
|
+
headers: list[str] = []
|
|
170
|
+
header_row = table.find("tr")
|
|
171
|
+
|
|
172
|
+
if header_row:
|
|
173
|
+
for cell in header_row.find_all(["th", "td"]):
|
|
174
|
+
text = cell.get_text(strip=True).lower()
|
|
175
|
+
text = re.sub(r"\s+", " ", text)
|
|
176
|
+
headers.append(text)
|
|
177
|
+
|
|
178
|
+
return headers
|
|
179
|
+
|
|
180
|
+
def _parse_row(self, cells: list[Any], headers: list[str], produto: str) -> Indicador | None:
|
|
181
|
+
"""Parseia uma linha da tabela."""
|
|
182
|
+
cell_texts = [c.get_text(strip=True) for c in cells]
|
|
183
|
+
|
|
184
|
+
data_value = None
|
|
185
|
+
valor_value = None
|
|
186
|
+
variacao_value = None
|
|
187
|
+
|
|
188
|
+
for _i, (header, cell_text) in enumerate(zip(headers, cell_texts)):
|
|
189
|
+
header_lower = header.lower()
|
|
190
|
+
|
|
191
|
+
if any(kw in header_lower for kw in ["data", "dia", "date"]):
|
|
192
|
+
data_value = self._parse_date(cell_text)
|
|
193
|
+
elif any(kw in header_lower for kw in ["valor", "preço", "preco", "r$", "price"]):
|
|
194
|
+
valor_value = self._parse_decimal(cell_text)
|
|
195
|
+
elif "var" in header_lower or "%" in header_lower:
|
|
196
|
+
variacao_value = cell_text
|
|
197
|
+
|
|
198
|
+
if not data_value and cell_texts:
|
|
199
|
+
data_value = self._parse_date(cell_texts[0])
|
|
200
|
+
|
|
201
|
+
if not valor_value and len(cell_texts) > 1:
|
|
202
|
+
for text in cell_texts[1:]:
|
|
203
|
+
parsed = self._parse_decimal(text)
|
|
204
|
+
if parsed and parsed > 0:
|
|
205
|
+
valor_value = parsed
|
|
206
|
+
break
|
|
207
|
+
|
|
208
|
+
if not data_value or not valor_value:
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
unidade = self._detect_unidade(produto, headers)
|
|
212
|
+
|
|
213
|
+
return Indicador(
|
|
214
|
+
fonte=Fonte.CEPEA,
|
|
215
|
+
produto=produto,
|
|
216
|
+
praca=None,
|
|
217
|
+
data=data_value,
|
|
218
|
+
valor=valor_value,
|
|
219
|
+
unidade=unidade,
|
|
220
|
+
metodologia="indicador_esalq",
|
|
221
|
+
revisao=0,
|
|
222
|
+
meta={"variacao": variacao_value} if variacao_value else {},
|
|
223
|
+
parser_version=self.version,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def _parse_date(self, text: str) -> date | None:
|
|
227
|
+
"""Parseia data de diferentes formatos."""
|
|
228
|
+
text = text.strip()
|
|
229
|
+
|
|
230
|
+
patterns = [
|
|
231
|
+
(r"(\d{2})/(\d{2})/(\d{4})", "%d/%m/%Y"),
|
|
232
|
+
(r"(\d{2})-(\d{2})-(\d{4})", "%d-%m-%Y"),
|
|
233
|
+
(r"(\d{4})-(\d{2})-(\d{2})", "%Y-%m-%d"),
|
|
234
|
+
(r"(\d{2})/(\d{2})/(\d{2})", "%d/%m/%y"),
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
for pattern, date_format in patterns:
|
|
238
|
+
match = re.search(pattern, text)
|
|
239
|
+
if match:
|
|
240
|
+
try:
|
|
241
|
+
return datetime.strptime(match.group(), date_format).date()
|
|
242
|
+
except ValueError:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
def _parse_decimal(self, text: str) -> Decimal | None:
|
|
248
|
+
"""Parseia valor decimal."""
|
|
249
|
+
text = text.strip()
|
|
250
|
+
|
|
251
|
+
text = re.sub(r"[R$\s]", "", text)
|
|
252
|
+
|
|
253
|
+
if "," in text and "." in text:
|
|
254
|
+
text = text.replace(".", "").replace(",", ".")
|
|
255
|
+
elif "," in text:
|
|
256
|
+
text = text.replace(",", ".")
|
|
257
|
+
|
|
258
|
+
text = re.sub(r"[^\d.\-]", "", text)
|
|
259
|
+
|
|
260
|
+
if not text or text == "." or text == "-":
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
value = Decimal(text)
|
|
265
|
+
return value if value > 0 else None
|
|
266
|
+
except InvalidOperation:
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
def _detect_unidade(self, produto: str, headers: list[str]) -> str:
|
|
270
|
+
"""Detecta unidade baseado no produto e headers."""
|
|
271
|
+
produto_lower = produto.lower()
|
|
272
|
+
|
|
273
|
+
unidades_produto = {
|
|
274
|
+
"soja": "BRL/sc60kg",
|
|
275
|
+
"milho": "BRL/sc60kg",
|
|
276
|
+
"cafe": "BRL/sc60kg",
|
|
277
|
+
"trigo": "BRL/sc60kg",
|
|
278
|
+
"arroz": "BRL/sc50kg",
|
|
279
|
+
"boi": "BRL/@",
|
|
280
|
+
"boi_gordo": "BRL/@",
|
|
281
|
+
"boi-gordo": "BRL/@",
|
|
282
|
+
"algodao": "BRL/@",
|
|
283
|
+
"frango": "BRL/kg",
|
|
284
|
+
"suino": "BRL/kg",
|
|
285
|
+
"acucar": "BRL/sc50kg",
|
|
286
|
+
"etanol": "BRL/L",
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
for key, unidade in unidades_produto.items():
|
|
290
|
+
if key in produto_lower:
|
|
291
|
+
return unidade
|
|
292
|
+
|
|
293
|
+
header_text = " ".join(headers).lower()
|
|
294
|
+
if "sc" in header_text or "saca" in header_text:
|
|
295
|
+
if "50" in header_text:
|
|
296
|
+
return "BRL/sc50kg"
|
|
297
|
+
return "BRL/sc60kg"
|
|
298
|
+
if "@" in header_text or "arroba" in header_text:
|
|
299
|
+
return "BRL/@"
|
|
300
|
+
if "kg" in header_text:
|
|
301
|
+
return "BRL/kg"
|
|
302
|
+
if "litro" in header_text or "/l" in header_text:
|
|
303
|
+
return "BRL/L"
|
|
304
|
+
|
|
305
|
+
return "BRL/sc60kg"
|
agrobr/cli.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""CLI do agrobr usando Typer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from agrobr import __version__, constants
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="agrobr",
|
|
14
|
+
help="Dados agricolas brasileiros em uma linha de codigo",
|
|
15
|
+
add_completion=False,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def version_callback(value: bool) -> None:
|
|
20
|
+
if value:
|
|
21
|
+
typer.echo(f"agrobr version {__version__}")
|
|
22
|
+
raise typer.Exit()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.callback() # type: ignore[misc]
|
|
26
|
+
def main(
|
|
27
|
+
_version: bool = typer.Option(
|
|
28
|
+
None,
|
|
29
|
+
"--version",
|
|
30
|
+
"-v",
|
|
31
|
+
help="Mostra a versao e sai",
|
|
32
|
+
callback=version_callback,
|
|
33
|
+
is_eager=True,
|
|
34
|
+
),
|
|
35
|
+
) -> None:
|
|
36
|
+
"""agrobr - Dados agricolas brasileiros."""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
cepea_app = typer.Typer(help="Indicadores CEPEA")
|
|
41
|
+
app.add_typer(cepea_app, name="cepea")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@cepea_app.command("indicador") # type: ignore[misc]
|
|
45
|
+
def cepea_indicador(
|
|
46
|
+
produto: str = typer.Argument(..., help="Produto (soja, milho, cafe, boi, etc)"),
|
|
47
|
+
_inicio: str | None = typer.Option(None, "--inicio", "-i", help="Data inicio (YYYY-MM-DD)"),
|
|
48
|
+
_fim: str | None = typer.Option(None, "--fim", "-f", help="Data fim (YYYY-MM-DD)"),
|
|
49
|
+
_ultimo: bool = typer.Option(False, "--ultimo", "-u", help="Apenas ultimo valor"),
|
|
50
|
+
_formato: str = typer.Option("table", "--formato", "-o", help="Formato: table, csv, json"),
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Consulta indicador CEPEA."""
|
|
53
|
+
typer.echo(f"Consultando {produto}...")
|
|
54
|
+
typer.echo("Funcionalidade em desenvolvimento")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command("health") # type: ignore[misc]
|
|
58
|
+
def health(
|
|
59
|
+
_all_sources: bool = typer.Option(False, "--all", "-a", help="Verifica todas as fontes"),
|
|
60
|
+
_source: str | None = typer.Option(None, "--source", "-s", help="Fonte especifica"),
|
|
61
|
+
output: str = typer.Option("text", "--output", "-o", help="Formato: text, json"),
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Executa health checks."""
|
|
64
|
+
typer.echo("Health check em desenvolvimento")
|
|
65
|
+
|
|
66
|
+
if output == "json":
|
|
67
|
+
result = {"status": "ok", "checks": []}
|
|
68
|
+
typer.echo(json.dumps(result, indent=2))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
cache_app = typer.Typer(help="Gerenciamento de cache")
|
|
72
|
+
app.add_typer(cache_app, name="cache")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@cache_app.command("status") # type: ignore[misc]
|
|
76
|
+
def cache_status() -> None:
|
|
77
|
+
"""Mostra status do cache."""
|
|
78
|
+
typer.echo("Status do cache em desenvolvimento")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@cache_app.command("clear") # type: ignore[misc]
|
|
82
|
+
def cache_clear(
|
|
83
|
+
_source: str | None = typer.Option(None, "--source", "-s", help="Limpar apenas fonte"),
|
|
84
|
+
_older_than: str | None = typer.Option(None, "--older-than", help="Ex: 30d"),
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Limpa o cache."""
|
|
87
|
+
typer.echo("Limpeza de cache em desenvolvimento")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
conab_app = typer.Typer(help="Dados CONAB - Safras e balanco")
|
|
91
|
+
app.add_typer(conab_app, name="conab")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@conab_app.command("safras") # type: ignore[misc]
|
|
95
|
+
def conab_safras(
|
|
96
|
+
produto: str = typer.Argument(..., help="Produto (soja, milho, arroz, feijao, etc)"),
|
|
97
|
+
safra: str | None = typer.Option(None, "--safra", "-s", help="Safra (ex: 2025/26)"),
|
|
98
|
+
uf: str | None = typer.Option(None, "--uf", "-u", help="Filtrar por UF"),
|
|
99
|
+
formato: str = typer.Option("table", "--formato", "-o", help="Formato: table, csv, json"),
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Consulta dados de safra por produto."""
|
|
102
|
+
import asyncio
|
|
103
|
+
|
|
104
|
+
from agrobr import conab
|
|
105
|
+
|
|
106
|
+
typer.echo(f"Consultando safras de {produto}...")
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
df = asyncio.run(conab.safras(produto=produto, safra=safra, uf=uf))
|
|
110
|
+
|
|
111
|
+
if df.empty:
|
|
112
|
+
typer.echo("Nenhum dado encontrado")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
if formato == "json":
|
|
116
|
+
typer.echo(df.to_json(orient="records", indent=2))
|
|
117
|
+
elif formato == "csv":
|
|
118
|
+
typer.echo(df.to_csv(index=False))
|
|
119
|
+
else:
|
|
120
|
+
typer.echo(df.to_string(index=False))
|
|
121
|
+
|
|
122
|
+
except Exception as e:
|
|
123
|
+
typer.echo(f"Erro: {e}", err=True)
|
|
124
|
+
raise typer.Exit(1) from None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@conab_app.command("balanco") # type: ignore[misc]
|
|
128
|
+
def conab_balanco(
|
|
129
|
+
produto: str | None = typer.Argument(None, help="Produto (opcional)"),
|
|
130
|
+
formato: str = typer.Option("table", "--formato", "-o", help="Formato: table, csv, json"),
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Consulta balanco de oferta e demanda."""
|
|
133
|
+
import asyncio
|
|
134
|
+
|
|
135
|
+
from agrobr import conab
|
|
136
|
+
|
|
137
|
+
typer.echo("Consultando balanco oferta/demanda...")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
df = asyncio.run(conab.balanco(produto=produto))
|
|
141
|
+
|
|
142
|
+
if df.empty:
|
|
143
|
+
typer.echo("Nenhum dado encontrado")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
if formato == "json":
|
|
147
|
+
typer.echo(df.to_json(orient="records", indent=2))
|
|
148
|
+
elif formato == "csv":
|
|
149
|
+
typer.echo(df.to_csv(index=False))
|
|
150
|
+
else:
|
|
151
|
+
typer.echo(df.to_string(index=False))
|
|
152
|
+
|
|
153
|
+
except Exception as e:
|
|
154
|
+
typer.echo(f"Erro: {e}", err=True)
|
|
155
|
+
raise typer.Exit(1) from None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@conab_app.command("levantamentos") # type: ignore[misc]
|
|
159
|
+
def conab_levantamentos() -> None:
|
|
160
|
+
"""Lista levantamentos disponiveis."""
|
|
161
|
+
import asyncio
|
|
162
|
+
|
|
163
|
+
from agrobr import conab
|
|
164
|
+
|
|
165
|
+
typer.echo("Listando levantamentos...")
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
levs = asyncio.run(conab.levantamentos())
|
|
169
|
+
|
|
170
|
+
for lev in levs[:10]:
|
|
171
|
+
typer.echo(f" {lev['safra']} - {lev['levantamento']}o levantamento")
|
|
172
|
+
|
|
173
|
+
if len(levs) > 10:
|
|
174
|
+
typer.echo(f" ... e mais {len(levs) - 10} levantamentos")
|
|
175
|
+
|
|
176
|
+
except Exception as e:
|
|
177
|
+
typer.echo(f"Erro: {e}", err=True)
|
|
178
|
+
raise typer.Exit(1) from None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@conab_app.command("produtos") # type: ignore[misc]
|
|
182
|
+
def conab_produtos() -> None:
|
|
183
|
+
"""Lista produtos disponiveis."""
|
|
184
|
+
import asyncio
|
|
185
|
+
|
|
186
|
+
from agrobr import conab
|
|
187
|
+
|
|
188
|
+
prods = asyncio.run(conab.produtos())
|
|
189
|
+
typer.echo("Produtos disponiveis:")
|
|
190
|
+
for prod in prods:
|
|
191
|
+
typer.echo(f" - {prod}")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# =============================================================================
|
|
195
|
+
# IBGE Commands
|
|
196
|
+
# =============================================================================
|
|
197
|
+
|
|
198
|
+
ibge_app = typer.Typer(help="Dados IBGE - PAM e LSPA")
|
|
199
|
+
app.add_typer(ibge_app, name="ibge")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@ibge_app.command("pam") # type: ignore[misc]
|
|
203
|
+
def ibge_pam(
|
|
204
|
+
produto: str = typer.Argument(..., help="Produto (soja, milho, arroz, etc)"),
|
|
205
|
+
ano: str | None = typer.Option(
|
|
206
|
+
None, "--ano", "-a", help="Ano ou anos (ex: 2023 ou 2020,2021,2022)"
|
|
207
|
+
),
|
|
208
|
+
uf: str | None = typer.Option(None, "--uf", "-u", help="Filtrar por UF"),
|
|
209
|
+
nivel: str = typer.Option("uf", "--nivel", "-n", help="Nivel: brasil, uf, municipio"),
|
|
210
|
+
formato: str = typer.Option("table", "--formato", "-o", help="Formato: table, csv, json"),
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Consulta dados da Producao Agricola Municipal (PAM)."""
|
|
213
|
+
import asyncio
|
|
214
|
+
|
|
215
|
+
from agrobr import ibge
|
|
216
|
+
|
|
217
|
+
typer.echo(f"Consultando PAM para {produto}...")
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
# Parse ano
|
|
221
|
+
ano_param: int | list[int] | None = None
|
|
222
|
+
if ano:
|
|
223
|
+
ano_param = [int(a.strip()) for a in ano.split(",")] if "," in ano else int(ano)
|
|
224
|
+
|
|
225
|
+
nivel_typed: Any = nivel # type validated by ibge.pam at runtime
|
|
226
|
+
df = asyncio.run(ibge.pam(produto=produto, ano=ano_param, uf=uf, nivel=nivel_typed))
|
|
227
|
+
|
|
228
|
+
if df.empty:
|
|
229
|
+
typer.echo("Nenhum dado encontrado")
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
if formato == "json":
|
|
233
|
+
typer.echo(df.to_json(orient="records", indent=2))
|
|
234
|
+
elif formato == "csv":
|
|
235
|
+
typer.echo(df.to_csv(index=False))
|
|
236
|
+
else:
|
|
237
|
+
typer.echo(df.to_string(index=False))
|
|
238
|
+
|
|
239
|
+
except Exception as e:
|
|
240
|
+
typer.echo(f"Erro: {e}", err=True)
|
|
241
|
+
raise typer.Exit(1) from None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@ibge_app.command("lspa") # type: ignore[misc]
|
|
245
|
+
def ibge_lspa(
|
|
246
|
+
produto: str = typer.Argument(..., help="Produto (soja, milho_1, milho_2, etc)"),
|
|
247
|
+
ano: int | None = typer.Option(None, "--ano", "-a", help="Ano de referencia"),
|
|
248
|
+
mes: int | None = typer.Option(None, "--mes", "-m", help="Mes (1-12)"),
|
|
249
|
+
uf: str | None = typer.Option(None, "--uf", "-u", help="Filtrar por UF"),
|
|
250
|
+
formato: str = typer.Option("table", "--formato", "-o", help="Formato: table, csv, json"),
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Consulta dados do Levantamento Sistematico da Producao Agricola (LSPA)."""
|
|
253
|
+
import asyncio
|
|
254
|
+
|
|
255
|
+
from agrobr import ibge
|
|
256
|
+
|
|
257
|
+
typer.echo(f"Consultando LSPA para {produto}...")
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
df = asyncio.run(ibge.lspa(produto=produto, ano=ano, mes=mes, uf=uf))
|
|
261
|
+
|
|
262
|
+
if df.empty:
|
|
263
|
+
typer.echo("Nenhum dado encontrado")
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
if formato == "json":
|
|
267
|
+
typer.echo(df.to_json(orient="records", indent=2))
|
|
268
|
+
elif formato == "csv":
|
|
269
|
+
typer.echo(df.to_csv(index=False))
|
|
270
|
+
else:
|
|
271
|
+
typer.echo(df.to_string(index=False))
|
|
272
|
+
|
|
273
|
+
except Exception as e:
|
|
274
|
+
typer.echo(f"Erro: {e}", err=True)
|
|
275
|
+
raise typer.Exit(1) from None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@ibge_app.command("produtos") # type: ignore[misc]
|
|
279
|
+
def ibge_produtos(
|
|
280
|
+
pesquisa: str = typer.Option("pam", "--pesquisa", "-p", help="Pesquisa: pam ou lspa"),
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Lista produtos disponiveis."""
|
|
283
|
+
import asyncio
|
|
284
|
+
|
|
285
|
+
from agrobr import ibge
|
|
286
|
+
|
|
287
|
+
if pesquisa == "pam":
|
|
288
|
+
prods = asyncio.run(ibge.produtos_pam())
|
|
289
|
+
typer.echo("Produtos disponiveis na PAM:")
|
|
290
|
+
else:
|
|
291
|
+
prods = asyncio.run(ibge.produtos_lspa())
|
|
292
|
+
typer.echo("Produtos disponiveis no LSPA:")
|
|
293
|
+
|
|
294
|
+
for prod in prods:
|
|
295
|
+
typer.echo(f" - {prod}")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
config_app = typer.Typer(help="Configuracoes")
|
|
299
|
+
app.add_typer(config_app, name="config")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@config_app.command("show") # type: ignore[misc]
|
|
303
|
+
def config_show() -> None:
|
|
304
|
+
"""Mostra configuracoes atuais."""
|
|
305
|
+
typer.echo("=== Cache Settings ===")
|
|
306
|
+
settings = constants.CacheSettings()
|
|
307
|
+
typer.echo(f" cache_dir: {settings.cache_dir}")
|
|
308
|
+
typer.echo(f" db_name: {settings.db_name}")
|
|
309
|
+
typer.echo(f" ttl_cepea_diario: {settings.ttl_cepea_diario}s")
|
|
310
|
+
|
|
311
|
+
typer.echo("\n=== HTTP Settings ===")
|
|
312
|
+
http = constants.HTTPSettings()
|
|
313
|
+
typer.echo(f" timeout_read: {http.timeout_read}s")
|
|
314
|
+
typer.echo(f" max_retries: {http.max_retries}")
|
|
315
|
+
|
|
316
|
+
typer.echo("\n=== Alert Settings ===")
|
|
317
|
+
alerts = constants.AlertSettings()
|
|
318
|
+
typer.echo(f" enabled: {alerts.enabled}")
|
|
319
|
+
typer.echo(f" slack_webhook: {'configured' if alerts.slack_webhook else 'not set'}")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__":
|
|
323
|
+
app()
|
agrobr/conab/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Modulo CONAB - Dados de safras e balanco oferta/demanda."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agrobr.conab.api import (
|
|
6
|
+
balanco,
|
|
7
|
+
brasil_total,
|
|
8
|
+
levantamentos,
|
|
9
|
+
produtos,
|
|
10
|
+
safras,
|
|
11
|
+
ufs,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"safras",
|
|
16
|
+
"balanco",
|
|
17
|
+
"brasil_total",
|
|
18
|
+
"levantamentos",
|
|
19
|
+
"produtos",
|
|
20
|
+
"ufs",
|
|
21
|
+
]
|