agrobr 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. agrobr/__init__.py +10 -0
  2. agrobr/alerts/__init__.py +7 -0
  3. agrobr/alerts/notifier.py +167 -0
  4. agrobr/cache/__init__.py +31 -0
  5. agrobr/cache/duckdb_store.py +433 -0
  6. agrobr/cache/history.py +317 -0
  7. agrobr/cache/migrations.py +82 -0
  8. agrobr/cache/policies.py +240 -0
  9. agrobr/cepea/__init__.py +7 -0
  10. agrobr/cepea/api.py +360 -0
  11. agrobr/cepea/client.py +273 -0
  12. agrobr/cepea/parsers/__init__.py +37 -0
  13. agrobr/cepea/parsers/base.py +35 -0
  14. agrobr/cepea/parsers/consensus.py +300 -0
  15. agrobr/cepea/parsers/detector.py +108 -0
  16. agrobr/cepea/parsers/fingerprint.py +226 -0
  17. agrobr/cepea/parsers/v1.py +305 -0
  18. agrobr/cli.py +323 -0
  19. agrobr/conab/__init__.py +21 -0
  20. agrobr/conab/api.py +239 -0
  21. agrobr/conab/client.py +219 -0
  22. agrobr/conab/parsers/__init__.py +7 -0
  23. agrobr/conab/parsers/v1.py +383 -0
  24. agrobr/constants.py +205 -0
  25. agrobr/exceptions.py +104 -0
  26. agrobr/health/__init__.py +23 -0
  27. agrobr/health/checker.py +202 -0
  28. agrobr/health/reporter.py +314 -0
  29. agrobr/http/__init__.py +9 -0
  30. agrobr/http/browser.py +214 -0
  31. agrobr/http/rate_limiter.py +69 -0
  32. agrobr/http/retry.py +93 -0
  33. agrobr/http/user_agents.py +67 -0
  34. agrobr/ibge/__init__.py +19 -0
  35. agrobr/ibge/api.py +273 -0
  36. agrobr/ibge/client.py +256 -0
  37. agrobr/models.py +85 -0
  38. agrobr/normalize/__init__.py +64 -0
  39. agrobr/normalize/dates.py +303 -0
  40. agrobr/normalize/encoding.py +102 -0
  41. agrobr/normalize/regions.py +308 -0
  42. agrobr/normalize/units.py +278 -0
  43. agrobr/noticias_agricolas/__init__.py +6 -0
  44. agrobr/noticias_agricolas/client.py +222 -0
  45. agrobr/noticias_agricolas/parser.py +187 -0
  46. agrobr/sync.py +147 -0
  47. agrobr/telemetry/__init__.py +17 -0
  48. agrobr/telemetry/collector.py +153 -0
  49. agrobr/utils/__init__.py +5 -0
  50. agrobr/utils/logging.py +59 -0
  51. agrobr/validators/__init__.py +35 -0
  52. agrobr/validators/sanity.py +286 -0
  53. agrobr/validators/structural.py +313 -0
  54. agrobr-0.1.0.dist-info/METADATA +243 -0
  55. agrobr-0.1.0.dist-info/RECORD +58 -0
  56. agrobr-0.1.0.dist-info/WHEEL +4 -0
  57. agrobr-0.1.0.dist-info/entry_points.txt +2 -0
  58. agrobr-0.1.0.dist-info/licenses/LICENSE +21 -0
agrobr/conab/api.py ADDED
@@ -0,0 +1,239 @@
1
+ """API pública do módulo CONAB."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import pandas as pd
8
+ import structlog
9
+
10
+ from agrobr import constants
11
+ from agrobr.conab import client
12
+ from agrobr.conab.parsers.v1 import ConabParserV1
13
+
14
+ logger = structlog.get_logger()
15
+
16
+
17
+ async def safras(
18
+ produto: str,
19
+ safra: str | None = None,
20
+ uf: str | None = None,
21
+ levantamento: int | None = None,
22
+ as_polars: bool = False,
23
+ ) -> pd.DataFrame:
24
+ """
25
+ Obtém dados de safra por produto.
26
+
27
+ Args:
28
+ produto: Nome do produto (soja, milho, arroz, feijao, algodao, trigo, etc)
29
+ safra: Safra no formato "2024/25" (default: mais recente)
30
+ uf: Filtrar por UF (ex: "MT", "PR")
31
+ levantamento: Número do levantamento (default: mais recente)
32
+ as_polars: Se True, retorna polars.DataFrame
33
+
34
+ Returns:
35
+ DataFrame com dados de safra por UF
36
+
37
+ Example:
38
+ >>> df = await conab.safras('soja', safra='2025/26')
39
+ >>> df = await conab.safras('milho', uf='MT')
40
+ """
41
+ logger.info(
42
+ "conab_safras_request",
43
+ produto=produto,
44
+ safra=safra,
45
+ uf=uf,
46
+ levantamento=levantamento,
47
+ )
48
+
49
+ xlsx, metadata = await client.fetch_safra_xlsx(safra=safra, levantamento=levantamento)
50
+
51
+ parser = ConabParserV1()
52
+ safra_list = parser.parse_safra_produto(
53
+ xlsx=xlsx,
54
+ produto=produto,
55
+ safra_ref=safra or metadata["safra"],
56
+ levantamento=metadata.get("levantamento"),
57
+ )
58
+
59
+ if uf:
60
+ safra_list = [s for s in safra_list if s.uf == uf.upper()]
61
+
62
+ if not safra_list:
63
+ logger.warning(
64
+ "conab_safras_empty",
65
+ produto=produto,
66
+ safra=safra,
67
+ uf=uf,
68
+ )
69
+ return pd.DataFrame()
70
+
71
+ df = pd.DataFrame([s.model_dump() for s in safra_list])
72
+
73
+ if as_polars:
74
+ try:
75
+ import polars as pl
76
+
77
+ return pl.from_pandas(df) # type: ignore[no-any-return]
78
+ except ImportError:
79
+ logger.warning("polars_not_installed", fallback="pandas")
80
+
81
+ logger.info(
82
+ "conab_safras_success",
83
+ produto=produto,
84
+ records=len(df),
85
+ )
86
+
87
+ return df
88
+
89
+
90
+ async def balanco(
91
+ produto: str | None = None,
92
+ safra: str | None = None,
93
+ as_polars: bool = False,
94
+ ) -> pd.DataFrame:
95
+ """
96
+ Obtém dados de balanço de oferta e demanda.
97
+
98
+ Args:
99
+ produto: Filtrar por produto (soja, milho, etc). None para todos.
100
+ safra: Safra de referência para o levantamento (default: mais recente)
101
+ as_polars: Se True, retorna polars.DataFrame
102
+
103
+ Returns:
104
+ DataFrame com balanço de oferta/demanda
105
+
106
+ Example:
107
+ >>> df = await conab.balanco('soja')
108
+ >>> df = await conab.balanco() # Todos os produtos
109
+ """
110
+ logger.info(
111
+ "conab_balanco_request",
112
+ produto=produto,
113
+ safra=safra,
114
+ )
115
+
116
+ xlsx, metadata = await client.fetch_safra_xlsx(safra=safra)
117
+
118
+ parser = ConabParserV1()
119
+ suprimentos = parser.parse_suprimento(xlsx=xlsx, produto=produto)
120
+
121
+ if not suprimentos:
122
+ logger.warning(
123
+ "conab_balanco_empty",
124
+ produto=produto,
125
+ )
126
+ return pd.DataFrame()
127
+
128
+ df = pd.DataFrame(suprimentos)
129
+
130
+ if as_polars:
131
+ try:
132
+ import polars as pl
133
+
134
+ return pl.from_pandas(df) # type: ignore[no-any-return]
135
+ except ImportError:
136
+ logger.warning("polars_not_installed", fallback="pandas")
137
+
138
+ logger.info(
139
+ "conab_balanco_success",
140
+ produto=produto,
141
+ records=len(df),
142
+ )
143
+
144
+ return df
145
+
146
+
147
+ async def brasil_total(
148
+ safra: str | None = None,
149
+ as_polars: bool = False,
150
+ ) -> pd.DataFrame:
151
+ """
152
+ Obtém totais do Brasil por produto.
153
+
154
+ Args:
155
+ safra: Safra de referência (default: mais recente)
156
+ as_polars: Se True, retorna polars.DataFrame
157
+
158
+ Returns:
159
+ DataFrame com totais por produto
160
+
161
+ Example:
162
+ >>> df = await conab.brasil_total()
163
+ >>> df = await conab.brasil_total(safra='2025/26')
164
+ """
165
+ logger.info(
166
+ "conab_brasil_total_request",
167
+ safra=safra,
168
+ )
169
+
170
+ xlsx, metadata = await client.fetch_safra_xlsx(safra=safra)
171
+
172
+ parser = ConabParserV1()
173
+ totais = parser.parse_brasil_total(xlsx=xlsx, safra_ref=safra)
174
+
175
+ if not totais:
176
+ logger.warning("conab_brasil_total_empty", safra=safra)
177
+ return pd.DataFrame()
178
+
179
+ df = pd.DataFrame(totais)
180
+
181
+ if as_polars:
182
+ try:
183
+ import polars as pl
184
+
185
+ return pl.from_pandas(df) # type: ignore[no-any-return]
186
+ except ImportError:
187
+ logger.warning("polars_not_installed", fallback="pandas")
188
+
189
+ logger.info(
190
+ "conab_brasil_total_success",
191
+ records=len(df),
192
+ )
193
+
194
+ return df
195
+
196
+
197
+ async def levantamentos() -> list[dict[str, Any]]:
198
+ """
199
+ Lista levantamentos de safra disponíveis.
200
+
201
+ Returns:
202
+ Lista de dicts com informações dos levantamentos
203
+
204
+ Example:
205
+ >>> levs = await conab.levantamentos()
206
+ >>> for lev in levs[:5]:
207
+ ... print(f"{lev['safra']} - {lev['levantamento']}º levantamento")
208
+ """
209
+ return await client.list_levantamentos()
210
+
211
+
212
+ async def produtos() -> list[str]:
213
+ """
214
+ Lista produtos disponíveis.
215
+
216
+ Returns:
217
+ Lista de nomes de produtos
218
+
219
+ Example:
220
+ >>> prods = await conab.produtos()
221
+ >>> print(prods)
222
+ ['soja', 'milho', 'arroz', 'feijao', ...]
223
+ """
224
+ return list(constants.CONAB_PRODUTOS.keys())
225
+
226
+
227
+ async def ufs() -> list[str]:
228
+ """
229
+ Lista UFs disponíveis.
230
+
231
+ Returns:
232
+ Lista de siglas de UF
233
+
234
+ Example:
235
+ >>> estados = await conab.ufs()
236
+ >>> print(estados)
237
+ ['AC', 'AL', 'AM', ...]
238
+ """
239
+ return constants.CONAB_UFS.copy()
agrobr/conab/client.py ADDED
@@ -0,0 +1,219 @@
1
+ """Cliente HTTP para download de dados da CONAB."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from io import BytesIO
7
+ from typing import Any
8
+
9
+ import structlog
10
+ from playwright.async_api import async_playwright
11
+
12
+ from agrobr import constants
13
+ from agrobr.exceptions import SourceUnavailableError
14
+ from agrobr.http.rate_limiter import RateLimiter
15
+ from agrobr.http.user_agents import UserAgentRotator
16
+
17
+ logger = structlog.get_logger()
18
+
19
+
20
+ async def fetch_boletim_page() -> str:
21
+ """
22
+ Busca página do boletim de safras de grãos da CONAB.
23
+
24
+ Returns:
25
+ HTML da página com lista de levantamentos
26
+ """
27
+ url = constants.URLS[constants.Fonte.CONAB]["boletim_graos"]
28
+
29
+ logger.info("conab_fetch_boletim_page", url=url)
30
+
31
+ async with RateLimiter.acquire(constants.Fonte.CONAB), async_playwright() as p:
32
+ browser = await p.chromium.launch(headless=True)
33
+ page = await browser.new_page(
34
+ user_agent=UserAgentRotator.get_random(),
35
+ viewport={"width": 1920, "height": 1080},
36
+ )
37
+
38
+ await page.goto(url, timeout=60000)
39
+ await page.wait_for_timeout(3000)
40
+
41
+ html: str = await page.content()
42
+ await browser.close()
43
+
44
+ logger.info(
45
+ "conab_fetch_boletim_success",
46
+ content_length=len(html),
47
+ )
48
+
49
+ return html
50
+
51
+
52
+ async def list_levantamentos(html: str | None = None) -> list[dict[str, Any]]:
53
+ """
54
+ Lista levantamentos disponíveis na página do boletim.
55
+
56
+ Args:
57
+ html: HTML da página (se None, busca automaticamente)
58
+
59
+ Returns:
60
+ Lista de dicts com informações dos levantamentos
61
+ """
62
+ if html is None:
63
+ html = await fetch_boletim_page()
64
+
65
+ levantamentos = []
66
+
67
+ pattern = r'href="([^"]+/(\d+)o-levantamento-safra-(\d{4})-(\d{2})/[^"]*\.xlsx)"[^>]*>([^<]*Tabela[^<]*)'
68
+
69
+ for match in re.finditer(pattern, html, re.IGNORECASE):
70
+ url = match.group(1)
71
+ num_levantamento = int(match.group(2))
72
+ ano_inicio = int(match.group(3))
73
+ ano_fim = int(match.group(4))
74
+
75
+ levantamentos.append(
76
+ {
77
+ "url": url,
78
+ "levantamento": num_levantamento,
79
+ "safra": f"{ano_inicio}/{ano_fim}",
80
+ "ano_inicio": ano_inicio,
81
+ "ano_fim": ano_fim,
82
+ }
83
+ )
84
+
85
+ levantamentos.sort(key=lambda x: (x["ano_inicio"], x["levantamento"]), reverse=True)
86
+
87
+ logger.info(
88
+ "conab_levantamentos_found",
89
+ count=len(levantamentos),
90
+ )
91
+
92
+ return levantamentos
93
+
94
+
95
+ async def download_xlsx(url: str) -> BytesIO:
96
+ """
97
+ Baixa arquivo XLSX da CONAB.
98
+
99
+ Args:
100
+ url: URL do arquivo XLSX
101
+
102
+ Returns:
103
+ BytesIO com conteúdo do arquivo
104
+
105
+ Raises:
106
+ SourceUnavailableError: Se não conseguir baixar
107
+ """
108
+ logger.info("conab_download_xlsx", url=url)
109
+
110
+ async with RateLimiter.acquire(constants.Fonte.CONAB), async_playwright() as p:
111
+ browser = await p.chromium.launch(headless=True)
112
+ context = await browser.new_context(accept_downloads=True)
113
+ page = await context.new_page()
114
+
115
+ try:
116
+ async with page.expect_download(timeout=60000) as download_info:
117
+ await page.evaluate(f'() => {{ window.location.href = "{url}" }}')
118
+
119
+ download = await download_info.value
120
+
121
+ path = await download.path()
122
+ if path:
123
+ with open(path, "rb") as f:
124
+ content = f.read()
125
+
126
+ logger.info(
127
+ "conab_download_success",
128
+ url=url,
129
+ size_bytes=len(content),
130
+ )
131
+
132
+ return BytesIO(content)
133
+ else:
134
+ raise SourceUnavailableError(
135
+ source="conab",
136
+ url=url,
137
+ last_error="Download path not available",
138
+ )
139
+
140
+ except Exception as e:
141
+ logger.error(
142
+ "conab_download_failed",
143
+ url=url,
144
+ error=str(e),
145
+ )
146
+ raise SourceUnavailableError(
147
+ source="conab",
148
+ url=url,
149
+ last_error=str(e),
150
+ ) from e
151
+
152
+ finally:
153
+ await browser.close()
154
+
155
+
156
+ async def fetch_latest_safra_xlsx() -> tuple[BytesIO, dict[str, Any]]:
157
+ """
158
+ Baixa planilha do levantamento mais recente.
159
+
160
+ Returns:
161
+ tuple: (BytesIO com arquivo, metadata do levantamento)
162
+ """
163
+ levantamentos = await list_levantamentos()
164
+
165
+ if not levantamentos:
166
+ raise SourceUnavailableError(
167
+ source="conab",
168
+ url=constants.URLS[constants.Fonte.CONAB]["boletim_graos"],
169
+ last_error="No levantamentos found",
170
+ )
171
+
172
+ latest = levantamentos[0]
173
+ xlsx = await download_xlsx(latest["url"])
174
+
175
+ return xlsx, latest
176
+
177
+
178
+ async def fetch_safra_xlsx(
179
+ safra: str | None = None,
180
+ levantamento: int | None = None,
181
+ ) -> tuple[BytesIO, dict[str, Any]]:
182
+ """
183
+ Baixa planilha de safra específica.
184
+
185
+ Args:
186
+ safra: Safra no formato "2024/25" (default: mais recente)
187
+ levantamento: Número do levantamento (default: mais recente)
188
+
189
+ Returns:
190
+ tuple: (BytesIO com arquivo, metadata do levantamento)
191
+ """
192
+ levantamentos = await list_levantamentos()
193
+
194
+ if not levantamentos:
195
+ raise SourceUnavailableError(
196
+ source="conab",
197
+ url=constants.URLS[constants.Fonte.CONAB]["boletim_graos"],
198
+ last_error="No levantamentos found",
199
+ )
200
+
201
+ filtered = levantamentos
202
+
203
+ if safra:
204
+ filtered = [lev for lev in filtered if lev["safra"] == safra]
205
+
206
+ if levantamento:
207
+ filtered = [lev for lev in filtered if lev["levantamento"] == levantamento]
208
+
209
+ if not filtered:
210
+ raise SourceUnavailableError(
211
+ source="conab",
212
+ url=constants.URLS[constants.Fonte.CONAB]["boletim_graos"],
213
+ last_error=f"No levantamento found for safra={safra}, levantamento={levantamento}",
214
+ )
215
+
216
+ target = filtered[0]
217
+ xlsx = await download_xlsx(target["url"])
218
+
219
+ return xlsx, target
@@ -0,0 +1,7 @@
1
+ """Parsers CONAB."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agrobr.conab.parsers.v1 import ConabParserV1
6
+
7
+ __all__ = ["ConabParserV1"]