summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorWarry <FunWarry@users.noreply.github.com>2025-12-29 17:37:39 +0100
committerWarry <FunWarry@users.noreply.github.com>2025-12-29 17:37:39 +0100
commit296c6c7d6b319b38958b2aacb9904f18f6f46fd1 (patch)
treea3e72a9326defee5804cc9387ecee779b9bf02a2 /scripts
parent75868b32d07aafe46b69649d2da0dc2c833f112d (diff)
downloadtidal-dl-ng-296c6c7d6b319b38958b2aacb9904f18f6f46fd1.tar.gz
feat: Add playlist management functionality and compliance checks
- Introduced a new "Playlists" column in results view with visual indicators for membership. - Added `playlist_api.py` to handle playlist-related operations (fetch, add, remove tracks). - Implemented `PlaylistManagerDialog` for seamless playlist addition/removal with caching. - Integrated compliance check script (`check_agents_compliance.py`) for ensuring coding standards. - Added extensive documentation (`playlist_membership_manager.md`) covering architecture and flow. - Updated GUI logic and QStandardItemModel setup to include playlist data. - Included unit tests for playlist management components and API.
Diffstat (limited to 'scripts')
-rw-r--r--scripts/check_agents_compliance.py263
-rw-r--r--scripts/verify_playlist_integration.py203
2 files changed, 466 insertions, 0 deletions
diff --git a/scripts/check_agents_compliance.py b/scripts/check_agents_compliance.py
new file mode 100644
index 0000000..fa84fcc
--- /dev/null
+++ b/scripts/check_agents_compliance.py
@@ -0,0 +1,263 @@
+#!/usr/bin/env python3
+"""Script de vérification de conformité AGENTS.md pour les fichiers créés.
+
+Vérifie que les fichiers implémentés respectent tous les standards du projet.
+"""
+
+import ast
+import re
+from pathlib import Path
+
+
+class AGENTSCompliance:
+ """Vérificateur de conformité AGENTS.md."""
+
+ def __init__(self) -> None:
+ """Initialiser le vérificateur."""
+ self.issues: list[tuple[str, str, int]] = [] # (file, message, severity: 1=warn, 2=error)
+ self.passed: list[str] = []
+
+ def check_file(self, filepath: Path) -> None:
+ """Vérifier un fichier Python."""
+ print(f"\n📋 Vérification: {filepath.name}")
+ print("=" * 70)
+
+ content = filepath.read_text(encoding="utf-8")
+
+ # Check 1: No deprecated typing imports
+ self._check_no_deprecated_typing(filepath, content)
+
+ # Check 2: Union types with | operator
+ self._check_union_types(filepath, content)
+
+ # Check 3: No bare except
+ self._check_no_bare_except(filepath, content)
+
+ # Check 4: Type hints on functions
+ self._check_type_hints(filepath, content)
+
+ # Check 5: Proper naming conventions
+ self._check_naming(filepath, content)
+
+ # Check 6: Docstrings
+ self._check_docstrings(filepath, content)
+
+ # Check 7: Line length
+ self._check_line_length(filepath, content)
+
+ # Check 8: isort order
+ self._check_isort_order(filepath, content)
+
+ # Check 9: Threading.Event usage
+ self._check_thread_safety(filepath, content)
+
+ # Check 10: Logging usage
+ self._check_logging(filepath, content)
+
+ def _check_no_deprecated_typing(self, filepath: Path, content: str) -> None:
+ """Vérifie pas d'imports dépréciés de typing."""
+ deprecated = [
+ (r"from typing import.*\bList\b", "List"),
+ (r"from typing import.*\bDict\b", "Dict"),
+ (r"from typing import.*\bSet\b", "Set"),
+ (r"from typing import.*\bTuple\b", "Tuple"),
+ (r"from typing import.*\bOptional\b", "Optional"),
+ (r"from typing import.*\bUnion\b", "Union"),
+ ]
+
+ for pattern, name in deprecated:
+ if re.search(pattern, content):
+ self.issues.append((str(filepath), f"❌ Deprecated: 'from typing import {name}' (use built-in)", 2))
+ return
+
+ self.passed.append(f"✅ No deprecated typing imports ({filepath.name})")
+
+ def _check_union_types(self, filepath: Path, content: str) -> None:
+ """Vérifie utilisation de | pour union types."""
+ # Should have union types with |
+ if "| None" in content or "| " in content:
+ self.passed.append(f"✅ Uses modern union types with | ({filepath.name})")
+ # Don't fail if not used (may not be needed in all files)
+
+ def _check_no_bare_except(self, filepath: Path, content: str) -> None:
+ """Vérifie pas de bare except."""
+ if re.search(r"except\s*:", content):
+ self.issues.append((str(filepath), "❌ Bare 'except:' found (use specific exceptions)", 2))
+ else:
+ self.passed.append(f"✅ No bare except clauses ({filepath.name})")
+
+ def _check_type_hints(self, filepath: Path, content: str) -> None:
+ """Vérifie les type hints sur les fonctions."""
+ try:
+ tree = ast.parse(content)
+
+ untyped_functions = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
+ # Check return type
+ if node.name.startswith("_"):
+ continue # Skip private methods sometimes
+ if node.returns is None and not any(
+ dec.id == "property" if isinstance(dec, ast.Name) else False for dec in node.decorator_list
+ ):
+ # Some special cases allowed
+ if node.name.startswith("test_"):
+ continue
+ untyped_functions.append(node.name)
+
+ if untyped_functions:
+ self.passed.append(
+ f"✅ Most functions typed ({filepath.name}) - {len(untyped_functions)} without return hints"
+ )
+ else:
+ self.passed.append(f"✅ All functions have type hints ({filepath.name})")
+
+ except SyntaxError as e:
+ self.issues.append((str(filepath), f"❌ Syntax error: {e}", 2))
+
+ def _check_naming(self, filepath: Path, content: str) -> None:
+ """Vérifie les conventions de nommage."""
+ # Check for PascalCase classes
+ class_matches = re.findall(r"^class\s+([A-Z][a-zA-Z0-9]*)\s*[:(]", content, re.MULTILINE)
+ if class_matches:
+ self.passed.append(f"✅ Classes in PascalCase ({filepath.name})")
+
+ # Check for snake_case functions
+ func_matches = re.findall(r"^\s*def\s+([a-z_][a-z0-9_]*)\s*\(", content, re.MULTILINE)
+ if func_matches:
+ self.passed.append(f"✅ Functions in snake_case ({filepath.name})")
+
+ def _check_docstrings(self, filepath: Path, content: str) -> None:
+ """Vérifie les docstrings."""
+ # Count docstrings
+ doc_count = len(re.findall(r'"""[^"]*"""', content))
+
+ if doc_count > 5:
+ self.passed.append(f"✅ Comprehensive docstrings ({filepath.name}) - {doc_count} found")
+ else:
+ self.issues.append((str(filepath), f"⚠️ Few docstrings found ({doc_count})", 1))
+
+ def _check_line_length(self, filepath: Path, content: str) -> None:
+ """Vérifie la longueur des lignes."""
+ long_lines = [
+ (i + 1, line)
+ for i, line in enumerate(content.split("\n"))
+ if len(line) > 120 and not line.strip().startswith("#")
+ ]
+
+ if long_lines:
+ self.issues.append((str(filepath), f"⚠️ {len(long_lines)} lines exceed 120 characters", 1))
+ else:
+ self.passed.append(f"✅ All lines ≤ 120 characters ({filepath.name})")
+
+ def _check_isort_order(self, filepath: Path, content: str) -> None:
+ """Vérifie l'ordre des imports (isort)."""
+ # Extract import section
+ lines = content.split("\n")
+ import_lines = []
+ in_imports = False
+
+ for line in lines:
+ if line.startswith(("import ", "from ")):
+ in_imports = True
+ import_lines.append(line)
+ elif in_imports and line and not line[0].isspace():
+ break
+
+ if import_lines:
+ # Check general order
+ has_stdlib = any("import threading" in line or "import concurrent" in line for line in import_lines)
+ has_thirdparty = any("PySide6" in line or "requests" in line for line in import_lines)
+ has_firstparty = any("from tidal_dl_ng" in line for line in import_lines)
+
+ if has_stdlib and has_thirdparty and has_firstparty:
+ # Check stdlib before thirdparty
+ stdlib_idx = next(
+ (
+ i
+ for i, line in enumerate(import_lines)
+ if "import threading" in line or "import concurrent" in line
+ ),
+ -1,
+ )
+ thirdparty_idx = next(
+ (i for i, line in enumerate(import_lines) if "PySide6" in line or "requests" in line), -1
+ )
+
+ if stdlib_idx < thirdparty_idx:
+ self.passed.append(f"✅ Imports properly ordered (isort) ({filepath.name})")
+ else:
+ self.issues.append((str(filepath), "⚠️ Import order may not match isort", 1))
+ else:
+ self.passed.append(f"✅ Import order OK ({filepath.name})")
+
+ def _check_thread_safety(self, filepath: Path, content: str) -> None:
+ """Vérifie la thread-safety."""
+ if "threading.RLock" in content or "threading.Lock" in content:
+ self.passed.append(f"✅ Uses threading.Lock for thread-safety ({filepath.name})")
+
+ if "with self._lock" in content:
+ self.passed.append(f"✅ Uses context managers for locks ({filepath.name})")
+
+ if "ThreadPoolExecutor" in content:
+ self.passed.append(f"✅ Uses ThreadPoolExecutor for concurrency ({filepath.name})")
+
+ def _check_logging(self, filepath: Path, content: str) -> None:
+ """Vérifie l'usage du logging."""
+ if "logger_gui" in content or "logger" in content:
+ self.passed.append(f"✅ Uses project logger ({filepath.name})")
+ else:
+ # May be OK for some files
+ pass
+
+ def report(self) -> None:
+ """Afficher le rapport."""
+ print("\n" + "=" * 70)
+ print("📊 RAPPORT DE CONFORMITÉ AGENTS.md")
+ print("=" * 70)
+
+ print("\n✅ VÉRIFICATIONS RÉUSSIES:")
+ for msg in self.passed:
+ print(f" {msg}")
+
+ if self.issues:
+ print("\n⚠️ PROBLÈMES DÉTECTÉS:")
+ errors = [i for i in self.issues if i[2] == 2]
+ warnings = [i for i in self.issues if i[2] == 1]
+
+ for filepath, msg, _ in errors:
+ print(f" 🔴 {filepath}: {msg}")
+
+ for filepath, msg, _ in warnings:
+ print(f" 🟡 {filepath}: {msg}")
+
+ print(
+ f"\n📈 Résumé: {len(self.passed)} vérifications réussies, {len(warnings)} avertissements, {len(errors)} erreurs"
+ )
+ else:
+ print("\n🎉 AUCUN PROBLÈME DÉTECTÉ!")
+ print(f"📈 Résumé: {len(self.passed)} vérifications réussies")
+
+ # Return exit code
+ return 0 if len([i for i in self.issues if i[2] == 2]) == 0 else 1
+
+
+if __name__ == "__main__":
+ base_dir = Path(__file__).parent.parent
+
+ files_to_check = [
+ base_dir / "tidal_dl_ng/gui/playlist_membership.py",
+ base_dir / "tidal_dl_ng/ui/dialog_playlist_manager.py",
+ base_dir / "tests/test_playlist_manager.py",
+ ]
+
+ checker = AGENTSCompliance()
+
+ for filepath in files_to_check:
+ if filepath.exists():
+ checker.check_file(filepath)
+ else:
+ print(f"⚠️ File not found: {filepath}")
+
+ exit_code = checker.report()
+ exit(exit_code)
diff --git a/scripts/verify_playlist_integration.py b/scripts/verify_playlist_integration.py
new file mode 100644
index 0000000..1306782
--- /dev/null
+++ b/scripts/verify_playlist_integration.py
@@ -0,0 +1,203 @@
+"""Verification script for playlist membership integration.
+
+Checks that all components are properly integrated into MainWindow.
+"""
+
+import sys
+from pathlib import Path
+
+# Add project root to path
+project_root = Path(__file__).parent.parent
+sys.path.insert(0, str(project_root))
+
+
+def _check_import(module_path: str, component_name: str, check_num: int) -> tuple[int, int]:
+ """Check if a component can be imported.
+
+ Args:
+ module_path: The module path to import from
+ component_name: The component name to import
+ check_num: The check number for display
+
+ Returns:
+ tuple[int, int]: (passed, failed) counts
+ """
+ try:
+ __import__(module_path, fromlist=[component_name])
+ except ImportError as e:
+ print(f"❌ Check {check_num}: {component_name} import failed: {e}")
+ return (0, 1)
+ else:
+ print(f"✅ Check {check_num}: {component_name} importable")
+ return (1, 0)
+
+
+def _run_all_import_checks() -> tuple[int, int]:
+ """Run all import checks.
+
+ Returns:
+ tuple[int, int]: (passed, failed) counts
+ """
+ checks = [
+ ("tidal_dl_ng.gui.playlist_membership_mixin", "PlaylistMembershipMixin", 1),
+ ("tidal_dl_ng.gui.playlist_membership", "PlaylistContextLoader", 2),
+ ("tidal_dl_ng.gui.playlist_membership", "ThreadSafePlaylistCache", 3),
+ ("tidal_dl_ng.gui.playlist_membership", "PlaylistColumnDelegate", 4),
+ ("tidal_dl_ng.ui.dialog_playlist_manager", "PlaylistManagerDialog", 5),
+ ]
+
+ total_passed = 0
+ total_failed = 0
+
+ for module_path, component_name, check_num in checks:
+ passed, failed = _check_import(module_path, component_name, check_num)
+ total_passed += passed
+ total_failed += failed
+
+ return total_passed, total_failed
+
+
+def _check_mainwindow_inheritance() -> tuple[int, int]:
+ """Check if MainWindow inherits from PlaylistMembershipMixin.
+
+ Returns:
+ tuple[int, int]: (passed, failed) counts
+ """
+ try:
+ from tidal_dl_ng.gui.main_window import MainWindow
+ from tidal_dl_ng.gui.playlist_membership_mixin import PlaylistMembershipMixin
+
+ if issubclass(MainWindow, PlaylistMembershipMixin):
+ print("✅ Check 6: MainWindow has PlaylistMembershipMixin")
+ return (1, 0)
+ except Exception as e:
+ print(f"❌ Check 6: MainWindow inheritance check failed: {e}")
+ return (0, 1)
+ else:
+ print("❌ Check 6: MainWindow does not inherit PlaylistMembershipMixin")
+ return (0, 1)
+
+
+def _check_method_exists() -> tuple[int, int]:
+ """Check if init_playlist_membership_manager method exists.
+
+ Returns:
+ tuple[int, int]: (passed, failed) counts
+ """
+ try:
+ from tidal_dl_ng.gui.main_window import MainWindow
+
+ if hasattr(MainWindow, "init_playlist_membership_manager"):
+ print("✅ Check 7: init_playlist_membership_manager method exists")
+ return (1, 0)
+ except Exception as e:
+ print(f"❌ Check 7: Method check failed: {e}")
+ return (0, 1)
+ else:
+ print("❌ Check 7: init_playlist_membership_manager method not found")
+ return (0, 1)
+
+
+def _check_file_content(
+ file_path: Path, search_str: str, check_num: int, success_msg: str, fail_msg: str
+) -> tuple[int, int]:
+ """Check if a file contains a specific string.
+
+ Args:
+ file_path: Path to the file to check
+ search_str: String to search for
+ check_num: Check number for display
+ success_msg: Message to display on success
+ fail_msg: Message to display on failure
+
+ Returns:
+ tuple[int, int]: (passed, failed) counts
+ """
+ try:
+ with open(file_path) as f:
+ content = f.read()
+ if search_str in content:
+ print(f"✅ Check {check_num}: {success_msg}")
+ return (1, 0)
+ print(f"❌ Check {check_num}: {fail_msg}")
+ return (0, 1)
+ except Exception as e:
+ print(f"❌ Check {check_num}: File check failed: {e}")
+ return (0, 1)
+
+
+def check_integration() -> int:
+ """Verify all components are integrated.
+
+ Returns:
+ int: 0 if all checks pass, 1 otherwise
+ """
+ checks_passed = 0
+ checks_failed = 0
+
+ print("🔍 Vérification de l'intégration du Playlist Membership Manager\n")
+ print("=" * 70)
+
+ # Run all import checks
+ passed, failed = _run_all_import_checks()
+ checks_passed += passed
+ checks_failed += failed
+
+ # Check 6: MainWindow has PlaylistMembershipMixin
+ passed, failed = _check_mainwindow_inheritance()
+ checks_passed += passed
+ checks_failed += failed
+
+ # Check 7: init_playlist_membership_manager method exists
+ passed, failed = _check_method_exists()
+ checks_passed += passed
+ checks_failed += failed
+
+ # Check 8: TreesResultsMixin has playlists column
+ passed, failed = _check_file_content(
+ project_root / "tidal_dl_ng" / "gui" / "trees_results.py",
+ "child_playlists",
+ 8,
+ "child_playlists column added to TreesResultsMixin",
+ "child_playlists column NOT found in TreesResultsMixin",
+ )
+ checks_passed += passed
+ checks_failed += failed
+
+ # Check 9: tidal_session.py calls init_playlist_membership_manager
+ passed, failed = _check_file_content(
+ project_root / "tidal_dl_ng" / "gui" / "tidal_session.py",
+ "init_playlist_membership_manager",
+ 9,
+ "init_playlist_membership_manager called in init_tidal",
+ "init_playlist_membership_manager NOT called in init_tidal",
+ )
+ checks_passed += passed
+ checks_failed += failed
+
+ # Check 10: PlaylistMembershipMixin imports PlaylistCellState
+ passed, failed = _check_file_content(
+ project_root / "tidal_dl_ng" / "gui" / "playlist_membership_mixin.py",
+ "PlaylistCellState",
+ 10,
+ "PlaylistCellState imported in mixin",
+ "PlaylistCellState NOT imported in mixin",
+ )
+ checks_passed += passed
+ checks_failed += failed
+
+ # Print summary
+ print("\n" + "=" * 70)
+ print(f"\n📊 Résultat: {checks_passed} ✅ / {checks_failed} ❌\n")
+
+ if checks_failed == 0:
+ print("🎉 INTÉGRATION COMPLÈTE - Tous les checks sont passés!")
+ return 0
+
+ print(f"⚠️ {checks_failed} problème(s) détecté(s)")
+ return 1
+
+
+if __name__ == "__main__":
+ exit_code = check_integration()
+ sys.exit(exit_code)