@danhachuel/thunderbolt 0.2.74 → 0.2.76

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.
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ MATERIAL_SOURCE_CATALOG: tuple[dict[str, str], ...] = (
7
+ {"code": "pexels", "label": "Pexels", "description": "Banco de vídeos e imagens para materiais da pipeline.", "legacy_key": "pexels_api_keys"},
8
+ {"code": "pixabay", "label": "Pixabay", "description": "Banco de vídeos e imagens para materiais da pipeline.", "legacy_key": "pixabay_api_keys"},
9
+ {"code": "coverr", "label": "Coverr", "description": "Fonte de vídeos de stock com API própria.", "legacy_key": "coverr_api_keys"},
10
+ {"code": "wavespeed", "label": "WaveSpeed AI", "description": "Geração de clips por IA; requer um serviço configurado.", "legacy_key": "wavespeed_api_keys"},
11
+ {"code": "loomloom", "label": "LoomLoom", "description": "Fonte paga de materiais; requer confirmação no fluxo de criação.", "legacy_key": "loomloom_api_keys"},
12
+ {"code": "twelvelabs", "label": "TwelveLabs", "description": "Ranking e análise semântica opcional dos materiais.", "legacy_key": "twelvelabs_api_keys"},
13
+ )
14
+
15
+
16
+ def _as_key_list(value: Any) -> list[str]:
17
+ if isinstance(value, list):
18
+ values = value
19
+ else:
20
+ values = str(value or "").replace("\n", ",").split(",")
21
+ result: list[str] = []
22
+ seen: set[str] = set()
23
+ for item in values:
24
+ key = str(item or "").strip()
25
+ if key and key not in seen:
26
+ result.append(key)
27
+ seen.add(key)
28
+ return result
29
+
30
+
31
+ def material_source_catalog() -> list[dict[str, str]]:
32
+ return [dict(item) for item in MATERIAL_SOURCE_CATALOG]
33
+
34
+
35
+ def material_api_keys(settings: dict[str, Any], source: str) -> list[str]:
36
+ code = str(source or "").strip().lower()
37
+ saved = settings.get("material_api_keys", {})
38
+ if isinstance(saved, dict) and code in saved:
39
+ return _as_key_list(saved.get(code))
40
+ legacy_key = next((item["legacy_key"] for item in MATERIAL_SOURCE_CATALOG if item["code"] == code), f"{code}_api_keys")
41
+ return _as_key_list(settings.get(legacy_key, ""))
42
+
43
+
44
+ def all_material_api_keys(settings: dict[str, Any]) -> dict[str, list[str]]:
45
+ return {item["code"]: material_api_keys(settings, item["code"]) for item in MATERIAL_SOURCE_CATALOG}
46
+
47
+
48
+ def update_material_api_keys(settings: dict[str, Any], source: str, keys: list[str]) -> dict[str, Any]:
49
+ code = str(source or "").strip().lower()
50
+ if code not in {item["code"] for item in MATERIAL_SOURCE_CATALOG}:
51
+ raise ValueError("Fonte de materiais inválida.")
52
+ cleaned = _as_key_list(keys)
53
+ mapping = all_material_api_keys(settings)
54
+ mapping[code] = cleaned
55
+ settings["material_api_keys"] = mapping
56
+ source_entry = next(item for item in MATERIAL_SOURCE_CATALOG if item["code"] == code)
57
+ settings[source_entry["legacy_key"]] = cleaned
58
+ return settings
59
+
60
+
61
+ def selected_material_source(settings: dict[str, Any]) -> str:
62
+ source = str(settings.get("video_source") or "pexels").strip().lower()
63
+ valid = {item["code"] for item in MATERIAL_SOURCE_CATALOG} | {"local"}
64
+ return source if source in valid else "pexels"
@@ -0,0 +1,313 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any, Callable, Iterable
9
+ from urllib.parse import urlsplit, urlunsplit
10
+
11
+ from . import storage
12
+ from .notifications import record_notification
13
+
14
+ try:
15
+ import yt_dlp # type: ignore
16
+ except ImportError: # pragma: no cover - exercised when the optional dependency is absent
17
+ yt_dlp = None
18
+
19
+
20
+ HISTORY_FILE = "media_downloads.json"
21
+ VIDEO_QUALITY_OPTIONS = {
22
+ "Melhor qualidade": "bv*+ba/b",
23
+ "1080p ou inferior": "bv*[height<=1080]+ba/b[height<=1080]",
24
+ "720p ou inferior": "bv*[height<=720]+ba/b[height<=720]",
25
+ "480p ou inferior": "bv*[height<=480]+ba/b[height<=480]",
26
+ }
27
+ VIDEO_CONTAINERS = ("mp4", "mkv", "webm")
28
+ AUDIO_FORMATS = ("mp3", "m4a", "wav", "opus")
29
+ ProgressCallback = Callable[[dict[str, Any]], None]
30
+
31
+
32
+ class MediaDownloadError(RuntimeError):
33
+ """Raised when a media download cannot be completed safely."""
34
+
35
+
36
+ def _now() -> str:
37
+ return datetime.now(timezone.utc).isoformat()
38
+
39
+
40
+ def _download_root() -> Path:
41
+ storage.ensure_storage()
42
+ storage.MEDIA_DOWNLOADS.mkdir(parents=True, exist_ok=True)
43
+ return storage.MEDIA_DOWNLOADS.resolve()
44
+
45
+
46
+ def _safe_download_path(value: str | Path) -> Path | None:
47
+ root = _download_root()
48
+ candidate = Path(value)
49
+ if not candidate.is_absolute():
50
+ candidate = root / candidate
51
+ try:
52
+ resolved = candidate.resolve()
53
+ resolved.relative_to(root)
54
+ except (OSError, ValueError):
55
+ return None
56
+ return resolved
57
+
58
+
59
+ def _relative_name(path: Path) -> str:
60
+ root = _download_root()
61
+ try:
62
+ return path.resolve().relative_to(root).as_posix()
63
+ except (OSError, ValueError):
64
+ return path.name
65
+
66
+
67
+ def _display_url(value: str) -> str:
68
+ parsed = urlsplit(value.strip())
69
+ if not parsed.scheme or not parsed.netloc:
70
+ return value.strip()[:120]
71
+ safe = urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
72
+ return safe[:160]
73
+
74
+
75
+ def _redact(value: Any) -> str:
76
+ text = str(value or "").strip()
77
+ text = re.sub(r"(?i)(authorization|bearer|api[_ -]?key|access[_ -]?token|cookie|session[_ -]?info)\s*[:=]?\s*[^\s,;]+", r"\1=[redacted]", text)
78
+ return text[:1200]
79
+
80
+
81
+ def normalize_urls(value: str | Iterable[str]) -> list[str]:
82
+ """Normalize one URL per line and reject unsafe/non-web inputs."""
83
+ if isinstance(value, str):
84
+ candidates = value.splitlines()
85
+ else:
86
+ candidates = list(value)
87
+ urls: list[str] = []
88
+ seen: set[str] = set()
89
+ for candidate in candidates:
90
+ url = str(candidate or "").strip()
91
+ if not url:
92
+ continue
93
+ if url.startswith("-"):
94
+ raise ValueError("Cada linha deve conter apenas uma URL; opções do yt-dlp não são aceites.")
95
+ parsed = urlsplit(url)
96
+ if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
97
+ raise ValueError(f"URL inválida ou não suportada: {_display_url(url)}")
98
+ normalized = urlunsplit((parsed.scheme.lower(), parsed.netloc, parsed.path, parsed.query, parsed.fragment))
99
+ if normalized not in seen:
100
+ urls.append(normalized)
101
+ seen.add(normalized)
102
+ if not urls:
103
+ raise ValueError("Introduza pelo menos uma URL http(s) para descarregar.")
104
+ return urls
105
+
106
+
107
+ def build_download_options(
108
+ *,
109
+ mode: str = "video",
110
+ quality: str = "Melhor qualidade",
111
+ container: str = "mp4",
112
+ audio_format: str = "mp3",
113
+ allow_playlist: bool = False,
114
+ download_subtitles: bool = False,
115
+ embed_metadata: bool = False,
116
+ progress_hook: Callable[[dict[str, Any]], None] | None = None,
117
+ ) -> dict[str, Any]:
118
+ """Build a constrained YoutubeDL options dictionary without user CLI flags."""
119
+ normalized_mode = str(mode or "video").strip().lower()
120
+ if normalized_mode not in {"video", "audio"}:
121
+ raise ValueError("O modo deve ser Vídeo ou Áudio.")
122
+ normalized_container = str(container or "mp4").lower()
123
+ normalized_audio = str(audio_format or "mp3").lower()
124
+ if normalized_container not in VIDEO_CONTAINERS:
125
+ raise ValueError("Contentor de vídeo não suportado.")
126
+ if normalized_audio not in AUDIO_FORMATS:
127
+ raise ValueError("Formato de áudio não suportado.")
128
+ root = _download_root()
129
+ options: dict[str, Any] = {
130
+ "outtmpl": str(root / "%(title).200B [%(id)s].%(ext)s"),
131
+ "noplaylist": not bool(allow_playlist),
132
+ "quiet": True,
133
+ "no_warnings": True,
134
+ "ignoreerrors": False,
135
+ "windowsfilenames": True,
136
+ "overwrites": False,
137
+ "paths": {"home": str(root)},
138
+ }
139
+ if progress_hook is not None:
140
+ options["progress_hooks"] = [progress_hook]
141
+ if normalized_mode == "audio":
142
+ options.update({"format": "bestaudio/best", "postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": normalized_audio, "preferredquality": "192"}]})
143
+ else:
144
+ options.update({"format": VIDEO_QUALITY_OPTIONS.get(quality, VIDEO_QUALITY_OPTIONS["Melhor qualidade"]), "merge_output_format": normalized_container})
145
+ if download_subtitles:
146
+ options.update({"writesubtitles": True, "writeautomaticsub": True, "subtitlesformat": "best", "subtitleslangs": ["all"]})
147
+ if embed_metadata:
148
+ options["addmetadata"] = True
149
+ return options
150
+
151
+
152
+ def _read_history() -> list[dict[str, Any]]:
153
+ records = storage.read_json(HISTORY_FILE, [])
154
+ return [item for item in records if isinstance(item, dict)] if isinstance(records, list) else []
155
+
156
+
157
+ def _write_history(records: list[dict[str, Any]]) -> None:
158
+ storage.write_json(HISTORY_FILE, records[:200])
159
+
160
+
161
+ def _upsert_history(record: dict[str, Any]) -> None:
162
+ history = [item for item in _read_history() if str(item.get("operation_id")) != str(record.get("operation_id"))]
163
+ _write_history([record, *history])
164
+
165
+
166
+ def list_media_downloads(limit: int = 50) -> list[dict[str, Any]]:
167
+ return _read_history()[: max(0, int(limit))]
168
+
169
+
170
+ def clear_media_download_history() -> int:
171
+ count = len(_read_history())
172
+ _write_history([])
173
+ return count
174
+
175
+
176
+ def media_download_file(record: dict[str, Any], filename: str) -> Path | None:
177
+ """Resolve a history filename strictly inside storage/downloads."""
178
+ allowed = {str(item) for item in record.get("files", []) if item}
179
+ if filename not in allowed:
180
+ return None
181
+ path = _safe_download_path(filename)
182
+ return path if path and path.is_file() else None
183
+
184
+
185
+ def dependency_status() -> dict[str, Any]:
186
+ return {"yt_dlp": yt_dlp is not None, "ffmpeg_note": "A conversão/combinação de streams pode exigir FFmpeg."}
187
+
188
+
189
+ def _files_from_info(info: Any, started_at: float) -> list[Path]:
190
+ candidates: list[str] = []
191
+ if isinstance(info, dict):
192
+ for key in ("filepath", "_filename", "filename"):
193
+ if info.get(key):
194
+ candidates.append(str(info[key]))
195
+ requested = info.get("requested_downloads")
196
+ if isinstance(requested, list):
197
+ for item in requested:
198
+ if isinstance(item, dict):
199
+ for key in ("filepath", "_filename", "filename"):
200
+ if item.get(key):
201
+ candidates.append(str(item[key]))
202
+ entries = info.get("entries")
203
+ if isinstance(entries, list):
204
+ for entry in entries:
205
+ candidates.extend(str(path) for path in _files_from_info(entry, started_at))
206
+ output: list[Path] = []
207
+ seen: set[str] = set()
208
+ for candidate in candidates:
209
+ path = _safe_download_path(candidate)
210
+ if path and path.is_file() and path.suffix.lower() not in {".part", ".ytdl"} and str(path) not in seen:
211
+ output.append(path)
212
+ seen.add(str(path))
213
+ root = _download_root()
214
+ try:
215
+ for path in root.rglob("*"):
216
+ if path.is_file() and path.suffix.lower() not in {".part", ".ytdl", ".json", ".description", ".vtt", ".srt", ".ass"} and path.stat().st_mtime >= started_at and str(path) not in seen:
217
+ output.append(path)
218
+ seen.add(str(path))
219
+ except OSError:
220
+ pass
221
+ return output
222
+
223
+
224
+ def _operation_id(url: str) -> str:
225
+ digest = hashlib.sha256(f"{url}|{_now()}|{uuid.uuid4().hex}".encode("utf-8")).hexdigest()[:16]
226
+ return f"media_{digest}"
227
+
228
+
229
+ def _notify(record: dict[str, Any]) -> None:
230
+ suffix = "concluído" if record.get("status") == "completed" else "falhou"
231
+ event_type = "media_download_completed" if record.get("status") == "completed" else "media_download_failed"
232
+ title = str(record.get("title") or record.get("display_url") or "Download de mídia")
233
+ message = f"O download de mídia {suffix}."
234
+ if record.get("status") == "failed" and record.get("error"):
235
+ message = f"O download de mídia falhou: {_redact(record['error'])}"
236
+ record_notification(
237
+ event_type,
238
+ title,
239
+ message,
240
+ metadata={"operation_id": record.get("operation_id"), "mode": record.get("mode"), "files": record.get("files", []), "display_url": record.get("display_url")},
241
+ dedupe_key=f"media:{record.get('operation_id')}:{record.get('status')}",
242
+ )
243
+
244
+
245
+ def download_media(
246
+ urls: str | Iterable[str],
247
+ *,
248
+ mode: str = "video",
249
+ quality: str = "Melhor qualidade",
250
+ container: str = "mp4",
251
+ audio_format: str = "mp3",
252
+ allow_playlist: bool = False,
253
+ download_subtitles: bool = False,
254
+ embed_metadata: bool = False,
255
+ progress_callback: ProgressCallback | None = None,
256
+ ) -> list[dict[str, Any]]:
257
+ """Download one or more public URLs and return one persisted record per URL."""
258
+ normalized_urls = normalize_urls(urls)
259
+ results: list[dict[str, Any]] = []
260
+ for url in normalized_urls:
261
+ operation_id = _operation_id(url)
262
+ record: dict[str, Any] = {
263
+ "operation_id": operation_id,
264
+ "url": _display_url(url),
265
+ "display_url": _display_url(url),
266
+ "mode": str(mode or "video").lower(),
267
+ "status": "processing",
268
+ "title": "",
269
+ "files": [],
270
+ "progress": 0.0,
271
+ "created_at": _now(),
272
+ "completed_at": "",
273
+ "error": "",
274
+ }
275
+ _upsert_history(record)
276
+ if progress_callback:
277
+ progress_callback({**record, "status": "processing"})
278
+ started_at = datetime.now().timestamp()
279
+
280
+ def progress_hook(payload: dict[str, Any]) -> None:
281
+ status = str(payload.get("status") or "")
282
+ downloaded = float(payload.get("downloaded_bytes") or 0)
283
+ total = float(payload.get("total_bytes") or payload.get("total_bytes_estimate") or 0)
284
+ progress = min(99.0, (downloaded / total) * 100) if total > 0 else (50.0 if status == "downloading" else 0.0)
285
+ record["progress"] = round(progress, 1)
286
+ if payload.get("filename"):
287
+ record["current_file"] = Path(str(payload["filename"])).name
288
+ if progress_callback:
289
+ progress_callback({**record, "hook_status": status})
290
+
291
+ try:
292
+ if yt_dlp is None:
293
+ raise MediaDownloadError("yt-dlp não está instalado. Instale as dependências do Thunderbolt e tente novamente.")
294
+ options = build_download_options(mode=mode, quality=quality, container=container, audio_format=audio_format, allow_playlist=allow_playlist, download_subtitles=download_subtitles, embed_metadata=embed_metadata, progress_hook=progress_hook)
295
+ downloader = yt_dlp.YoutubeDL(options)
296
+ info = downloader.extract_info(url, download=True)
297
+ close = getattr(downloader, "close", None)
298
+ if callable(close):
299
+ close()
300
+ files = _files_from_info(info, started_at)
301
+ if not files:
302
+ raise MediaDownloadError("O yt-dlp terminou sem produzir um ficheiro local verificável.")
303
+ record.update({"status": "completed", "title": str(info.get("title") or "Download concluído") if isinstance(info, dict) else "Download concluído", "files": [_relative_name(path) for path in files], "progress": 100.0, "completed_at": _now(), "error": ""})
304
+ _upsert_history(record)
305
+ _notify(record)
306
+ except Exception as exc: # the UI receives a persisted failed record per URL
307
+ record.update({"status": "failed", "error": _redact(exc), "completed_at": _now()})
308
+ _upsert_history(record)
309
+ _notify(record)
310
+ results.append(dict(record))
311
+ if progress_callback:
312
+ progress_callback(dict(record))
313
+ return results
@@ -26,6 +26,8 @@ EVENT_CATALOG: tuple[dict[str, str], ...] = (
26
26
  {"code": "cuts_completed", "category": "Edição", "label": "Cortes concluídos", "description": "Quando a geração de cortes terminar com um manifesto completo."},
27
27
  {"code": "metadata_cleaning_completed", "category": "Edição", "label": "Metadados limpos", "description": "Quando uma cópia com metadados limpos for criada."},
28
28
  {"code": "python_edit_completed", "category": "Edição", "label": "Edição Python concluída", "description": "Quando uma operação do Editor Python guardar o artefacto."},
29
+ {"code": "media_download_completed", "category": "Edição", "label": "Download Mídia concluído", "description": "Quando um vídeo ou áudio terminar de ser descarregado com sucesso."},
30
+ {"code": "media_download_failed", "category": "Edição", "label": "Download Mídia falhou", "description": "Quando um download de vídeo ou áudio terminar com erro."},
29
31
  {"code": "automation_completed", "category": "Automação", "label": "Automação concluída", "description": "Quando o worker concluir o lote agendado de um canal."},
30
32
  {"code": "automation_failed", "category": "Automação", "label": "Automação falhou", "description": "Quando uma execução automática terminar com erro."},
31
33
  {"code": "activity_failed", "category": "Sistema", "label": "Actividade falhou", "description": "Quando uma tarefa ou operação persistida terminar em erro."},
@@ -13,6 +13,7 @@ STORAGE = Path(os.getenv("THUNDERBOLT_STORAGE_DIR") or ROOT / "storage")
13
13
  STATE = STORAGE / "state"
14
14
  BLUEPRINTS = STORAGE / "blueprints"
15
15
  TIKTOK_PROMPT_MASTERS = STORAGE / "tiktok" / "prompts_master"
16
+ MEDIA_DOWNLOADS = STORAGE / "downloads"
16
17
  NICHES_DATA = STORAGE / "data" / "niches"
17
18
  SEED_BLUEPRINTS = ROOT / "seed" / "blueprints"
18
19
  SEED_TIKTOK_PROMPT_MASTERS = ROOT / "seed" / "prompt_masters"
@@ -25,6 +26,7 @@ DEFAULTS: dict[str, Any] = {
25
26
  "batches.json": [],
26
27
  "uploads.json": [],
27
28
  "notifications.json": [],
29
+ "media_downloads.json": [],
28
30
  "display_names.json": {"blueprints": {}, "prompt_masters": {}},
29
31
  "niche_apify_runs.json": [],
30
32
  "metadata_edits.json": [],
@@ -195,9 +197,12 @@ DEFAULTS: dict[str, Any] = {
195
197
  "endpoint": "",
196
198
  "proxy_http": "",
197
199
  "proxy_https": "",
200
+ "material_api_keys": {},
198
201
  "pexels_api_keys": "",
199
202
  "pixabay_api_keys": "",
200
203
  "coverr_api_keys": "",
204
+ "wavespeed_api_keys": "",
205
+ "loomloom_api_keys": "",
201
206
  "twelvelabs_api_keys": "",
202
207
  "sonilo_api_key": "",
203
208
  "subtitle_provider": "edge",
@@ -283,7 +288,7 @@ def seed_prompt_masters() -> None:
283
288
 
284
289
 
285
290
  def ensure_storage() -> None:
286
- for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", TIKTOK_PROMPT_MASTERS, STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews", STORAGE / "python_editor", NICHES_DATA]:
291
+ for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", TIKTOK_PROMPT_MASTERS, MEDIA_DOWNLOADS, STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews", STORAGE / "python_editor", NICHES_DATA]:
287
292
  path.mkdir(parents=True, exist_ok=True)
288
293
  seed_blueprints()
289
294
  seed_prompt_masters()
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
  from typing import Any
5
5
 
6
+ from hermes_ui.material_sources import all_material_api_keys, selected_material_source
7
+
6
8
  try:
7
9
  import toml
8
10
  except ImportError: # pragma: no cover - installation fallback
@@ -112,9 +114,13 @@ def build_moneyprinter_config(settings: dict[str, Any], existing: dict[str, Any]
112
114
  for settings_key, config_key in app_map.items():
113
115
  if settings_key in settings:
114
116
  app[config_key] = settings[settings_key]
115
- for key in ("pexels_api_keys", "pixabay_api_keys", "coverr_api_keys", "twelvelabs_api_keys"):
116
- if key in settings:
117
- app[key] = _list_value(settings[key])
117
+ # A UI can store the canonical mapping while older installations still
118
+ # expose one legacy field per source. Export every supported source as a
119
+ # TOML array so MoneyPrinterTurbo can rotate keys without CSV parsing.
120
+ canonical_material_keys = all_material_api_keys(settings)
121
+ for source, keys in canonical_material_keys.items():
122
+ app[f"{source}_api_keys"] = _list_value(keys)
123
+ app["video_source"] = selected_material_source(settings)
118
124
  config["app"] = app
119
125
 
120
126
  whisper["model_size"] = settings.get("whisper_model_size", whisper.get("model_size", "large-v3"))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.74",
3
+ "version": "0.2.76",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",
package/requirements.txt CHANGED
@@ -11,5 +11,6 @@ scikit-learn>=1.3,<2
11
11
  mlxtend>=0.23,<1
12
12
  plotly>=5.18,<7
13
13
  seaborn>=0.13,<1
14
+ yt-dlp>=2025.1.15,<2027
14
15
  matplotlib>=3.7,<4
15
16
  kagglehub>=0.3,<1