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
agrobr/cache/history.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Gerenciamento de histórico permanente.
|
|
3
|
+
|
|
4
|
+
O histórico é separado do cache volátil e nunca expira automaticamente.
|
|
5
|
+
Permite reconstruir séries históricas e auditar mudanças de parsing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import date, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import structlog
|
|
15
|
+
|
|
16
|
+
from ..constants import Fonte
|
|
17
|
+
|
|
18
|
+
logger = structlog.get_logger()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HistoryManager:
|
|
22
|
+
"""
|
|
23
|
+
Gerenciador de histórico permanente.
|
|
24
|
+
|
|
25
|
+
O histórico armazena dados imutáveis coletados ao longo do tempo,
|
|
26
|
+
permitindo:
|
|
27
|
+
- Reconstrução de séries históricas
|
|
28
|
+
- Auditoria de mudanças de parsing
|
|
29
|
+
- Fallback quando fonte está indisponível
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, store: Any = None):
|
|
33
|
+
"""
|
|
34
|
+
Inicializa o gerenciador.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
store: DuckDBStore (opcional, usa singleton se não fornecido)
|
|
38
|
+
"""
|
|
39
|
+
self._store = store
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def store(self) -> Any:
|
|
43
|
+
"""Obtém store, criando se necessário."""
|
|
44
|
+
if self._store is None:
|
|
45
|
+
from .duckdb_store import get_store
|
|
46
|
+
|
|
47
|
+
self._store = get_store()
|
|
48
|
+
return self._store
|
|
49
|
+
|
|
50
|
+
def save(
|
|
51
|
+
self,
|
|
52
|
+
key: str,
|
|
53
|
+
data: bytes,
|
|
54
|
+
source: Fonte,
|
|
55
|
+
data_date: date,
|
|
56
|
+
parser_version: int,
|
|
57
|
+
fingerprint_hash: str | None = None,
|
|
58
|
+
) -> bool:
|
|
59
|
+
"""
|
|
60
|
+
Salva dados no histórico.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
key: Chave identificadora (ex: 'cepea:soja')
|
|
64
|
+
data: Dados serializados
|
|
65
|
+
source: Fonte de dados
|
|
66
|
+
data_date: Data dos dados (não da coleta)
|
|
67
|
+
parser_version: Versão do parser usado
|
|
68
|
+
fingerprint_hash: Hash do fingerprint (opcional)
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
True se salvo, False se já existia
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
self.store.history_save(
|
|
75
|
+
key=key,
|
|
76
|
+
data=data,
|
|
77
|
+
source=source,
|
|
78
|
+
data_date=datetime.combine(data_date, datetime.min.time()),
|
|
79
|
+
parser_version=parser_version,
|
|
80
|
+
fingerprint_hash=fingerprint_hash,
|
|
81
|
+
)
|
|
82
|
+
logger.debug(
|
|
83
|
+
"history_saved",
|
|
84
|
+
key=key,
|
|
85
|
+
data_date=str(data_date),
|
|
86
|
+
parser_version=parser_version,
|
|
87
|
+
)
|
|
88
|
+
return True
|
|
89
|
+
except Exception as e:
|
|
90
|
+
logger.debug("history_save_skipped", key=key, reason=str(e))
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
def get(
|
|
94
|
+
self,
|
|
95
|
+
key: str,
|
|
96
|
+
data_date: date | None = None,
|
|
97
|
+
) -> bytes | None:
|
|
98
|
+
"""
|
|
99
|
+
Busca dados no histórico.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
key: Chave identificadora
|
|
103
|
+
data_date: Data específica (ou mais recente se None)
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Dados ou None
|
|
107
|
+
"""
|
|
108
|
+
dt = datetime.combine(data_date, datetime.min.time()) if data_date else None
|
|
109
|
+
result: bytes | None = self.store.history_get(key, dt)
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
def get_latest(self, key: str) -> bytes | None:
|
|
113
|
+
"""
|
|
114
|
+
Busca dados mais recentes no histórico.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
key: Chave identificadora
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Dados mais recentes ou None
|
|
121
|
+
"""
|
|
122
|
+
return self.get(key, None)
|
|
123
|
+
|
|
124
|
+
def query(
|
|
125
|
+
self,
|
|
126
|
+
source: Fonte | None = None,
|
|
127
|
+
start_date: date | None = None,
|
|
128
|
+
end_date: date | None = None,
|
|
129
|
+
key_prefix: str | None = None,
|
|
130
|
+
) -> list[dict[str, Any]]:
|
|
131
|
+
"""
|
|
132
|
+
Consulta histórico com filtros.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
source: Filtrar por fonte
|
|
136
|
+
start_date: Data inicial
|
|
137
|
+
end_date: Data final
|
|
138
|
+
key_prefix: Prefixo da chave
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Lista de metadados das entradas
|
|
142
|
+
"""
|
|
143
|
+
start_dt = datetime.combine(start_date, datetime.min.time()) if start_date else None
|
|
144
|
+
end_dt = datetime.combine(end_date, datetime.max.time()) if end_date else None
|
|
145
|
+
|
|
146
|
+
entries: list[dict[str, Any]] = self.store.history_query(
|
|
147
|
+
source=source,
|
|
148
|
+
start_date=start_dt,
|
|
149
|
+
end_date=end_dt,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if key_prefix:
|
|
153
|
+
entries = [e for e in entries if e["key"].startswith(key_prefix)]
|
|
154
|
+
|
|
155
|
+
return entries
|
|
156
|
+
|
|
157
|
+
def get_dates(
|
|
158
|
+
self,
|
|
159
|
+
key: str,
|
|
160
|
+
start_date: date | None = None,
|
|
161
|
+
end_date: date | None = None,
|
|
162
|
+
) -> list[date]:
|
|
163
|
+
"""
|
|
164
|
+
Retorna datas disponíveis no histórico para uma chave.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
key: Chave identificadora
|
|
168
|
+
start_date: Data inicial (opcional)
|
|
169
|
+
end_date: Data final (opcional)
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Lista de datas
|
|
173
|
+
"""
|
|
174
|
+
entries = self.query(key_prefix=key, start_date=start_date, end_date=end_date)
|
|
175
|
+
dates = set()
|
|
176
|
+
|
|
177
|
+
for entry in entries:
|
|
178
|
+
if entry["key"] == key:
|
|
179
|
+
data_date = entry.get("data_date")
|
|
180
|
+
if data_date:
|
|
181
|
+
if isinstance(data_date, datetime):
|
|
182
|
+
dates.add(data_date.date())
|
|
183
|
+
else:
|
|
184
|
+
dates.add(data_date)
|
|
185
|
+
|
|
186
|
+
return sorted(dates)
|
|
187
|
+
|
|
188
|
+
def find_gaps(
|
|
189
|
+
self,
|
|
190
|
+
key: str,
|
|
191
|
+
start_date: date,
|
|
192
|
+
end_date: date,
|
|
193
|
+
) -> list[date]:
|
|
194
|
+
"""
|
|
195
|
+
Encontra lacunas no histórico.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
key: Chave identificadora
|
|
199
|
+
start_date: Data inicial
|
|
200
|
+
end_date: Data final
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Lista de datas sem dados
|
|
204
|
+
"""
|
|
205
|
+
available = set(self.get_dates(key, start_date, end_date))
|
|
206
|
+
|
|
207
|
+
all_dates = []
|
|
208
|
+
current = start_date
|
|
209
|
+
while current <= end_date:
|
|
210
|
+
if current.weekday() < 5:
|
|
211
|
+
all_dates.append(current)
|
|
212
|
+
current = current + __import__("datetime").timedelta(days=1)
|
|
213
|
+
|
|
214
|
+
return [d for d in all_dates if d not in available]
|
|
215
|
+
|
|
216
|
+
def count(
|
|
217
|
+
self,
|
|
218
|
+
source: Fonte | None = None,
|
|
219
|
+
key_prefix: str | None = None,
|
|
220
|
+
) -> int:
|
|
221
|
+
"""
|
|
222
|
+
Conta entradas no histórico.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
source: Filtrar por fonte
|
|
226
|
+
key_prefix: Prefixo da chave
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Número de entradas
|
|
230
|
+
"""
|
|
231
|
+
entries = self.query(source=source, key_prefix=key_prefix)
|
|
232
|
+
return len(entries)
|
|
233
|
+
|
|
234
|
+
def export(
|
|
235
|
+
self,
|
|
236
|
+
path: str | Path,
|
|
237
|
+
source: Fonte | None = None,
|
|
238
|
+
start_date: date | None = None,
|
|
239
|
+
end_date: date | None = None,
|
|
240
|
+
format: str = "parquet",
|
|
241
|
+
) -> int:
|
|
242
|
+
"""
|
|
243
|
+
Exporta histórico para arquivo.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
path: Caminho do arquivo
|
|
247
|
+
source: Filtrar por fonte
|
|
248
|
+
start_date: Data inicial
|
|
249
|
+
end_date: Data final
|
|
250
|
+
format: Formato ('parquet', 'csv', 'json')
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Número de registros exportados
|
|
254
|
+
"""
|
|
255
|
+
import pandas as pd
|
|
256
|
+
|
|
257
|
+
entries = self.query(source=source, start_date=start_date, end_date=end_date)
|
|
258
|
+
|
|
259
|
+
if not entries:
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
df = pd.DataFrame(entries)
|
|
263
|
+
|
|
264
|
+
path = Path(path)
|
|
265
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
|
|
267
|
+
if format == "parquet":
|
|
268
|
+
df.to_parquet(path, index=False)
|
|
269
|
+
elif format == "csv":
|
|
270
|
+
df.to_csv(path, index=False)
|
|
271
|
+
elif format == "json":
|
|
272
|
+
df.to_json(path, orient="records", date_format="iso")
|
|
273
|
+
else:
|
|
274
|
+
raise ValueError(f"Formato não suportado: {format}")
|
|
275
|
+
|
|
276
|
+
logger.info("history_exported", path=str(path), count=len(df), format=format)
|
|
277
|
+
return len(df)
|
|
278
|
+
|
|
279
|
+
def cleanup(
|
|
280
|
+
self,
|
|
281
|
+
older_than_days: int | None = None,
|
|
282
|
+
source: Fonte | None = None,
|
|
283
|
+
) -> int:
|
|
284
|
+
"""
|
|
285
|
+
Remove entradas antigas do histórico.
|
|
286
|
+
|
|
287
|
+
ATENÇÃO: Operação destrutiva! O histórico normalmente não deve ser limpo.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
older_than_days: Remover entradas mais antigas que N dias
|
|
291
|
+
source: Filtrar por fonte
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Número de entradas removidas
|
|
295
|
+
"""
|
|
296
|
+
if older_than_days is None:
|
|
297
|
+
logger.warning("history_cleanup_skipped", reason="no_age_specified")
|
|
298
|
+
return 0
|
|
299
|
+
|
|
300
|
+
logger.warning(
|
|
301
|
+
"history_cleanup_starting",
|
|
302
|
+
older_than_days=older_than_days,
|
|
303
|
+
source=source.value if source else "all",
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
return 0
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
_history_manager: HistoryManager | None = None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def get_history_manager() -> HistoryManager:
|
|
313
|
+
"""Obtém instância singleton do HistoryManager."""
|
|
314
|
+
global _history_manager
|
|
315
|
+
if _history_manager is None:
|
|
316
|
+
_history_manager = HistoryManager()
|
|
317
|
+
return _history_manager
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Schema migrations para DuckDB."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import duckdb
|
|
12
|
+
|
|
13
|
+
logger = structlog.get_logger()
|
|
14
|
+
|
|
15
|
+
SCHEMA_VERSION = 3
|
|
16
|
+
|
|
17
|
+
MIGRATIONS: dict[int, str] = {
|
|
18
|
+
1: """
|
|
19
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
20
|
+
version INTEGER PRIMARY KEY,
|
|
21
|
+
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
22
|
+
);
|
|
23
|
+
INSERT OR IGNORE INTO schema_version (version) VALUES (1);
|
|
24
|
+
""",
|
|
25
|
+
2: """
|
|
26
|
+
ALTER TABLE cache_entries ADD COLUMN IF NOT EXISTS hit_count INTEGER DEFAULT 0;
|
|
27
|
+
ALTER TABLE cache_entries ADD COLUMN IF NOT EXISTS stale BOOLEAN DEFAULT FALSE;
|
|
28
|
+
""",
|
|
29
|
+
3: """
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_history_key_date ON history_entries(key, data_date);
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_history_parser ON history_entries(parser_version);
|
|
32
|
+
""",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_current_version(conn: duckdb.DuckDBPyConnection) -> int:
|
|
37
|
+
"""Retorna versão atual do schema."""
|
|
38
|
+
try:
|
|
39
|
+
result = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()
|
|
40
|
+
return int(result[0]) if result and result[0] else 0
|
|
41
|
+
except Exception:
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def migrate(conn: duckdb.DuckDBPyConnection) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Executa migrations pendentes.
|
|
48
|
+
|
|
49
|
+
Migrations são idempotentes e podem ser re-executadas com segurança.
|
|
50
|
+
"""
|
|
51
|
+
current = get_current_version(conn)
|
|
52
|
+
|
|
53
|
+
if current >= SCHEMA_VERSION:
|
|
54
|
+
logger.debug("schema_up_to_date", version=current)
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
logger.info("schema_migration_start", current=current, target=SCHEMA_VERSION)
|
|
58
|
+
|
|
59
|
+
for version in range(current + 1, SCHEMA_VERSION + 1):
|
|
60
|
+
if version in MIGRATIONS:
|
|
61
|
+
try:
|
|
62
|
+
for statement in MIGRATIONS[version].strip().split(";"):
|
|
63
|
+
statement = statement.strip()
|
|
64
|
+
if statement:
|
|
65
|
+
try:
|
|
66
|
+
conn.execute(statement)
|
|
67
|
+
except Exception as stmt_error:
|
|
68
|
+
if "already exists" in str(stmt_error).lower():
|
|
69
|
+
continue
|
|
70
|
+
if "duplicate" in str(stmt_error).lower():
|
|
71
|
+
continue
|
|
72
|
+
raise
|
|
73
|
+
|
|
74
|
+
with contextlib.suppress(Exception):
|
|
75
|
+
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [version])
|
|
76
|
+
|
|
77
|
+
logger.info("migration_applied", version=version)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.error("migration_failed", version=version, error=str(e))
|
|
80
|
+
raise
|
|
81
|
+
|
|
82
|
+
logger.info("schema_migration_complete", version=SCHEMA_VERSION)
|
agrobr/cache/policies.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Políticas de cache e TTL por fonte.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import NamedTuple
|
|
10
|
+
|
|
11
|
+
from ..constants import Fonte
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CachePolicy(NamedTuple):
|
|
15
|
+
"""Política de cache para uma fonte."""
|
|
16
|
+
|
|
17
|
+
ttl_seconds: int
|
|
18
|
+
stale_max_seconds: int
|
|
19
|
+
description: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TTL(Enum):
|
|
23
|
+
"""TTLs pré-definidos."""
|
|
24
|
+
|
|
25
|
+
MINUTES_15 = 15 * 60
|
|
26
|
+
MINUTES_30 = 30 * 60
|
|
27
|
+
HOUR_1 = 60 * 60
|
|
28
|
+
HOURS_4 = 4 * 60 * 60
|
|
29
|
+
HOURS_12 = 12 * 60 * 60
|
|
30
|
+
HOURS_24 = 24 * 60 * 60
|
|
31
|
+
DAYS_7 = 7 * 24 * 60 * 60
|
|
32
|
+
DAYS_30 = 30 * 24 * 60 * 60
|
|
33
|
+
DAYS_90 = 90 * 24 * 60 * 60
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
POLICIES: dict[str, CachePolicy] = {
|
|
37
|
+
"cepea_diario": CachePolicy(
|
|
38
|
+
ttl_seconds=TTL.HOURS_4.value,
|
|
39
|
+
stale_max_seconds=TTL.HOURS_24.value * 2,
|
|
40
|
+
description="CEPEA indicador diário (atualiza ~18h)",
|
|
41
|
+
),
|
|
42
|
+
"cepea_semanal": CachePolicy(
|
|
43
|
+
ttl_seconds=TTL.HOURS_24.value,
|
|
44
|
+
stale_max_seconds=TTL.DAYS_7.value,
|
|
45
|
+
description="CEPEA indicador semanal (atualiza sexta)",
|
|
46
|
+
),
|
|
47
|
+
"conab_safras": CachePolicy(
|
|
48
|
+
ttl_seconds=TTL.HOURS_24.value,
|
|
49
|
+
stale_max_seconds=TTL.DAYS_30.value,
|
|
50
|
+
description="CONAB safras (atualiza mensalmente)",
|
|
51
|
+
),
|
|
52
|
+
"conab_balanco": CachePolicy(
|
|
53
|
+
ttl_seconds=TTL.HOURS_24.value,
|
|
54
|
+
stale_max_seconds=TTL.DAYS_30.value,
|
|
55
|
+
description="CONAB balanço (atualiza mensalmente)",
|
|
56
|
+
),
|
|
57
|
+
"ibge_pam": CachePolicy(
|
|
58
|
+
ttl_seconds=TTL.DAYS_7.value,
|
|
59
|
+
stale_max_seconds=TTL.DAYS_90.value,
|
|
60
|
+
description="IBGE PAM (atualiza anualmente)",
|
|
61
|
+
),
|
|
62
|
+
"ibge_lspa": CachePolicy(
|
|
63
|
+
ttl_seconds=TTL.HOURS_24.value,
|
|
64
|
+
stale_max_seconds=TTL.DAYS_30.value,
|
|
65
|
+
description="IBGE LSPA (atualiza mensalmente)",
|
|
66
|
+
),
|
|
67
|
+
"noticias_agricolas": CachePolicy(
|
|
68
|
+
ttl_seconds=TTL.HOURS_4.value,
|
|
69
|
+
stale_max_seconds=TTL.HOURS_24.value * 2,
|
|
70
|
+
description="Notícias Agrícolas (mirror CEPEA)",
|
|
71
|
+
),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
SOURCE_POLICY_MAP: dict[Fonte, str] = {
|
|
75
|
+
Fonte.CEPEA: "cepea_diario",
|
|
76
|
+
Fonte.CONAB: "conab_safras",
|
|
77
|
+
Fonte.IBGE: "ibge_lspa",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_policy(source: Fonte | str, endpoint: str | None = None) -> CachePolicy:
|
|
82
|
+
"""
|
|
83
|
+
Retorna política de cache para uma fonte/endpoint.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
source: Fonte de dados
|
|
87
|
+
endpoint: Endpoint específico (opcional)
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
CachePolicy aplicável
|
|
91
|
+
"""
|
|
92
|
+
if isinstance(source, str):
|
|
93
|
+
if source in POLICIES:
|
|
94
|
+
return POLICIES[source]
|
|
95
|
+
try:
|
|
96
|
+
source = Fonte(source)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return POLICIES["cepea_diario"]
|
|
99
|
+
|
|
100
|
+
if endpoint:
|
|
101
|
+
key = f"{source.value}_{endpoint}"
|
|
102
|
+
if key in POLICIES:
|
|
103
|
+
return POLICIES[key]
|
|
104
|
+
|
|
105
|
+
default_key = SOURCE_POLICY_MAP.get(source, "cepea_diario")
|
|
106
|
+
return POLICIES[default_key]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_ttl(source: Fonte | str, endpoint: str | None = None) -> int:
|
|
110
|
+
"""
|
|
111
|
+
Retorna TTL em segundos para uma fonte.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
source: Fonte de dados
|
|
115
|
+
endpoint: Endpoint específico
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
TTL em segundos
|
|
119
|
+
"""
|
|
120
|
+
return get_policy(source, endpoint).ttl_seconds
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_stale_max(source: Fonte | str, endpoint: str | None = None) -> int:
|
|
124
|
+
"""
|
|
125
|
+
Retorna tempo máximo stale em segundos.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
source: Fonte de dados
|
|
129
|
+
endpoint: Endpoint específico
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Stale máximo em segundos
|
|
133
|
+
"""
|
|
134
|
+
return get_policy(source, endpoint).stale_max_seconds
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def is_expired(created_at: datetime, source: Fonte | str) -> bool:
|
|
138
|
+
"""
|
|
139
|
+
Verifica se entrada de cache está expirada.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
created_at: Data de criação
|
|
143
|
+
source: Fonte de dados
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
True se expirado
|
|
147
|
+
"""
|
|
148
|
+
ttl = get_ttl(source)
|
|
149
|
+
expires_at = created_at + timedelta(seconds=ttl)
|
|
150
|
+
return datetime.utcnow() > expires_at
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def is_stale_acceptable(created_at: datetime, source: Fonte | str) -> bool:
|
|
154
|
+
"""
|
|
155
|
+
Verifica se dados stale ainda são aceitáveis.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
created_at: Data de criação
|
|
159
|
+
source: Fonte de dados
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
True se stale ainda é aceitável
|
|
163
|
+
"""
|
|
164
|
+
stale_max = get_stale_max(source)
|
|
165
|
+
max_acceptable = created_at + timedelta(seconds=stale_max)
|
|
166
|
+
return datetime.utcnow() <= max_acceptable
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def calculate_expiry(source: Fonte | str, endpoint: str | None = None) -> datetime:
|
|
170
|
+
"""
|
|
171
|
+
Calcula data de expiração para nova entrada.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
source: Fonte de dados
|
|
175
|
+
endpoint: Endpoint específico
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Data de expiração
|
|
179
|
+
"""
|
|
180
|
+
ttl = get_ttl(source, endpoint)
|
|
181
|
+
return datetime.utcnow() + timedelta(seconds=ttl)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class InvalidationReason(Enum):
|
|
185
|
+
"""Razões para invalidação de cache."""
|
|
186
|
+
|
|
187
|
+
EXPIRED = "expired"
|
|
188
|
+
MANUAL = "manual"
|
|
189
|
+
SOURCE_UPDATE = "source_update"
|
|
190
|
+
PARSE_ERROR = "parse_error"
|
|
191
|
+
VALIDATION_ERROR = "validation_error"
|
|
192
|
+
FINGERPRINT_CHANGE = "fingerprint_change"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def should_refresh(
|
|
196
|
+
created_at: datetime,
|
|
197
|
+
source: Fonte | str,
|
|
198
|
+
force: bool = False,
|
|
199
|
+
) -> tuple[bool, str]:
|
|
200
|
+
"""
|
|
201
|
+
Determina se cache deve ser atualizado.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
created_at: Data de criação do cache
|
|
205
|
+
source: Fonte de dados
|
|
206
|
+
force: Forçar atualização
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Tupla (deve_atualizar, razão)
|
|
210
|
+
"""
|
|
211
|
+
if force:
|
|
212
|
+
return True, "force_refresh"
|
|
213
|
+
|
|
214
|
+
if is_expired(created_at, source):
|
|
215
|
+
return True, "expired"
|
|
216
|
+
|
|
217
|
+
return False, "fresh"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def format_ttl(seconds: int) -> str:
|
|
221
|
+
"""
|
|
222
|
+
Formata TTL para exibição.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
seconds: TTL em segundos
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
String formatada (ex: "4 horas", "7 dias")
|
|
229
|
+
"""
|
|
230
|
+
if seconds < 60:
|
|
231
|
+
return f"{seconds} segundos"
|
|
232
|
+
if seconds < 3600:
|
|
233
|
+
minutes = seconds // 60
|
|
234
|
+
return f"{minutes} minuto{'s' if minutes > 1 else ''}"
|
|
235
|
+
if seconds < 86400:
|
|
236
|
+
hours = seconds // 3600
|
|
237
|
+
return f"{hours} hora{'s' if hours > 1 else ''}"
|
|
238
|
+
|
|
239
|
+
days = seconds // 86400
|
|
240
|
+
return f"{days} dia{'s' if days > 1 else ''}"
|