codegraphcontext-rust 0.5.5__cp311-cp311-win_amd64.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 (101) hide show
  1. codegraphcontext/__init__.py +2 -0
  2. codegraphcontext/__main__.py +5 -0
  3. codegraphcontext/_cgc_rust.cp311-win_amd64.pyd +0 -0
  4. codegraphcontext/cli/__init__.py +1 -0
  5. codegraphcontext/cli/cli_helpers.py +774 -0
  6. codegraphcontext/cli/config_manager.py +1051 -0
  7. codegraphcontext/cli/main.py +2386 -0
  8. codegraphcontext/cli/registry_commands.py +404 -0
  9. codegraphcontext/cli/setup_macos.py +93 -0
  10. codegraphcontext/cli/setup_wizard.py +991 -0
  11. codegraphcontext/cli/visualizer.py +50 -0
  12. codegraphcontext/core/__init__.py +165 -0
  13. codegraphcontext/core/bundle_registry.py +181 -0
  14. codegraphcontext/core/cgc_bundle.py +858 -0
  15. codegraphcontext/core/cgcignore.py +118 -0
  16. codegraphcontext/core/database.py +273 -0
  17. codegraphcontext/core/database_falkordb.py +480 -0
  18. codegraphcontext/core/database_falkordb_remote.py +199 -0
  19. codegraphcontext/core/database_kuzu.py +636 -0
  20. codegraphcontext/core/falkor_worker.py +132 -0
  21. codegraphcontext/core/jobs.py +132 -0
  22. codegraphcontext/core/watcher.py +260 -0
  23. codegraphcontext/prompts.py +125 -0
  24. codegraphcontext/server.py +471 -0
  25. codegraphcontext/tool_definitions.py +220 -0
  26. codegraphcontext/tools/__init__.py +0 -0
  27. codegraphcontext/tools/advanced_language_query_tool.py +103 -0
  28. codegraphcontext/tools/code_finder.py +1160 -0
  29. codegraphcontext/tools/graph_builder.py +337 -0
  30. codegraphcontext/tools/handlers/analysis_handlers.py +114 -0
  31. codegraphcontext/tools/handlers/indexing_handlers.py +116 -0
  32. codegraphcontext/tools/handlers/management_handlers.py +339 -0
  33. codegraphcontext/tools/handlers/query_handlers.py +83 -0
  34. codegraphcontext/tools/handlers/watcher_handlers.py +83 -0
  35. codegraphcontext/tools/indexing/__init__.py +1 -0
  36. codegraphcontext/tools/indexing/constants.py +25 -0
  37. codegraphcontext/tools/indexing/discovery.py +64 -0
  38. codegraphcontext/tools/indexing/engine.py +109 -0
  39. codegraphcontext/tools/indexing/persistence/__init__.py +3 -0
  40. codegraphcontext/tools/indexing/persistence/parquet_loader.py +643 -0
  41. codegraphcontext/tools/indexing/persistence/writer.py +1114 -0
  42. codegraphcontext/tools/indexing/pipeline.py +171 -0
  43. codegraphcontext/tools/indexing/pre_scan.py +105 -0
  44. codegraphcontext/tools/indexing/resolution/__init__.py +8 -0
  45. codegraphcontext/tools/indexing/resolution/calls.py +253 -0
  46. codegraphcontext/tools/indexing/resolution/inheritance.py +91 -0
  47. codegraphcontext/tools/indexing/sanitize.py +41 -0
  48. codegraphcontext/tools/indexing/schema.py +79 -0
  49. codegraphcontext/tools/indexing/schema_contract.py +44 -0
  50. codegraphcontext/tools/indexing/scip_pipeline.py +140 -0
  51. codegraphcontext/tools/languages/c.py +563 -0
  52. codegraphcontext/tools/languages/cpp.py +616 -0
  53. codegraphcontext/tools/languages/csharp.py +551 -0
  54. codegraphcontext/tools/languages/dart.py +378 -0
  55. codegraphcontext/tools/languages/elixir.py +460 -0
  56. codegraphcontext/tools/languages/go.py +499 -0
  57. codegraphcontext/tools/languages/haskell.py +426 -0
  58. codegraphcontext/tools/languages/java.py +500 -0
  59. codegraphcontext/tools/languages/javascript.py +589 -0
  60. codegraphcontext/tools/languages/kotlin.py +640 -0
  61. codegraphcontext/tools/languages/perl.py +260 -0
  62. codegraphcontext/tools/languages/php.py +519 -0
  63. codegraphcontext/tools/languages/python.py +576 -0
  64. codegraphcontext/tools/languages/ruby.py +537 -0
  65. codegraphcontext/tools/languages/rust.py +295 -0
  66. codegraphcontext/tools/languages/scala.py +519 -0
  67. codegraphcontext/tools/languages/swift.py +491 -0
  68. codegraphcontext/tools/languages/typescript.py +575 -0
  69. codegraphcontext/tools/languages/typescriptjsx.py +151 -0
  70. codegraphcontext/tools/package_resolver.py +472 -0
  71. codegraphcontext/tools/query_tool_languages/c_toolkit.py +5 -0
  72. codegraphcontext/tools/query_tool_languages/cpp_toolkit.py +87 -0
  73. codegraphcontext/tools/query_tool_languages/csharp_toolkit.py +5 -0
  74. codegraphcontext/tools/query_tool_languages/dart_toolkit.py +5 -0
  75. codegraphcontext/tools/query_tool_languages/go_toolkit.py +5 -0
  76. codegraphcontext/tools/query_tool_languages/haskell_toolkit.py +5 -0
  77. codegraphcontext/tools/query_tool_languages/java_toolkit.py +5 -0
  78. codegraphcontext/tools/query_tool_languages/javascript_toolkit.py +5 -0
  79. codegraphcontext/tools/query_tool_languages/perl_toolkit.py +5 -0
  80. codegraphcontext/tools/query_tool_languages/python_toolkit.py +5 -0
  81. codegraphcontext/tools/query_tool_languages/ruby_toolkit.py +5 -0
  82. codegraphcontext/tools/query_tool_languages/rust_toolkit.py +5 -0
  83. codegraphcontext/tools/query_tool_languages/scala_toolkit.py +5 -0
  84. codegraphcontext/tools/query_tool_languages/swift_toolkit.py +5 -0
  85. codegraphcontext/tools/query_tool_languages/typescript_toolkit.py +5 -0
  86. codegraphcontext/tools/scip_indexer.py +467 -0
  87. codegraphcontext/tools/scip_pb2.py +2455 -0
  88. codegraphcontext/tools/system.py +133 -0
  89. codegraphcontext/tools/tree_sitter_parser.py +103 -0
  90. codegraphcontext/utils/debug_log.py +90 -0
  91. codegraphcontext/utils/path_ignore.py +55 -0
  92. codegraphcontext/utils/repo_path.py +27 -0
  93. codegraphcontext/utils/tree_sitter_manager.py +285 -0
  94. codegraphcontext/utils/visualize_graph.py +0 -0
  95. codegraphcontext/viz/server.py +282 -0
  96. codegraphcontext_rust-0.5.5.dist-info/METADATA +537 -0
  97. codegraphcontext_rust-0.5.5.dist-info/RECORD +101 -0
  98. codegraphcontext_rust-0.5.5.dist-info/WHEEL +4 -0
  99. codegraphcontext_rust-0.5.5.dist-info/entry_points.txt +3 -0
  100. codegraphcontext_rust-0.5.5.dist-info/licenses/LICENSE +21 -0
  101. codegraphcontext_rust-0.5.5.dist-info/sboms/cgc-python.cyclonedx.json +2333 -0
@@ -0,0 +1,50 @@
1
+ import urllib.parse
2
+ from typing import Optional, List, Dict, Any, Set
3
+ from pathlib import Path
4
+ from .cli_helpers import visualize_helper
5
+
6
+ def check_visual_flag(ctx, visual: bool, cypher_query: str = None):
7
+ """
8
+ Helper to check the --visual flag and launch the visualizer.
9
+ This is called from within analyze/find commands.
10
+ """
11
+ if visual and cypher_query:
12
+ # We start the visualizer on port 8000
13
+ # Passing empty repo handles showing just the query results
14
+ port = 8000
15
+ encoded_query = urllib.parse.quote(cypher_query)
16
+ visualization_url = f"http://localhost:{port}/explore?cypher_query={encoded_query}"
17
+
18
+ from rich.console import Console
19
+ console = Console(stderr=True)
20
+ console.print(f"[green]Starting visualizer...[/green]")
21
+ console.print(f"[cyan]Visualizing results at:[/cyan] {visualization_url}")
22
+
23
+ # Start the backend server and open the browser
24
+ visualize_helper(repo_path=None, port=port)
25
+ return True
26
+ return False
27
+
28
+ def visualize_call_graph(cypher_query: str):
29
+ """Visualize a call graph result."""
30
+ visualize_helper(repo_path=None, port=8000)
31
+
32
+ def visualize_call_chain(cypher_query: str):
33
+ """Visualize a call chain result."""
34
+ visualize_helper(repo_path=None, port=8000)
35
+
36
+ def visualize_dependencies(cypher_query: str):
37
+ """Visualize code dependencies."""
38
+ visualize_helper(repo_path=None, port=8000)
39
+
40
+ def visualize_inheritance_tree(cypher_query: str):
41
+ """Visualize class inheritance tree."""
42
+ visualize_helper(repo_path=None, port=8000)
43
+
44
+ def visualize_overrides(cypher_query: str):
45
+ """Visualize method overrides."""
46
+ visualize_helper(repo_path=None, port=8000)
47
+
48
+ def visualize_search_results(cypher_query: str):
49
+ """Visualize search results."""
50
+ visualize_helper(repo_path=None, port=8000)
@@ -0,0 +1,165 @@
1
+ # src/codegraphcontext/core/__init__.py
2
+ """
3
+ Core database management module.
4
+
5
+ Supports Neo4j, FalkorDB Lite, remote FalkorDB, and KùzuDB backends.
6
+
7
+ Explicit backend selection (see ``get_database_manager``):
8
+ - ``CGC_RUNTIME_DB_TYPE`` — per-invocation override (CLI ``--database`` / MCP resolved context).
9
+ - ``DEFAULT_DATABASE`` — configured default from ``cgc config db …`` / CodeGraphContext ``.env``.
10
+
11
+ When neither is set, implicit selection:
12
+ - Remote FalkorDB if ``FALKORDB_HOST`` is set (explicit remote signal).
13
+ - Else **Unix**: FalkorDB Lite when Python 3.12+ and ``falkordblite`` work; else KùzuDB if
14
+ installed; else Neo4j if credentials exist.
15
+ - Else **Windows**: KùzuDB if installed; else Neo4j if credentials exist.
16
+ """
17
+ import os
18
+ import platform
19
+ from typing import Union, Optional
20
+ import importlib.util
21
+
22
+ def _is_kuzudb_available() -> bool:
23
+ """Check if KùzuDB is installed."""
24
+ try:
25
+ return importlib.util.find_spec("kuzu") is not None
26
+ except ImportError:
27
+ return False
28
+
29
+ def _is_falkordb_available() -> bool:
30
+ """Check if FalkorDB Lite is installed (Unix only)."""
31
+ if platform.system() == "Windows":
32
+ return False
33
+
34
+ import sys
35
+ if sys.version_info < (3, 12):
36
+ return False
37
+ try:
38
+ import redislite
39
+ return hasattr(redislite, 'falkordb_client')
40
+ except ImportError:
41
+ return False
42
+
43
+ def _is_falkordb_remote_configured() -> bool:
44
+ """Check if a remote FalkorDB host is configured."""
45
+ return bool(os.getenv('FALKORDB_HOST'))
46
+
47
+ def _is_neo4j_configured() -> bool:
48
+ """Check if Neo4j is configured with credentials."""
49
+ return all([
50
+ os.getenv('NEO4J_URI'),
51
+ os.getenv('NEO4J_USERNAME'),
52
+ os.getenv('NEO4J_PASSWORD')
53
+ ])
54
+
55
+ def get_database_manager(db_path: Optional[str] = None) -> Union['DatabaseManager', 'FalkorDBManager', 'FalkorDBRemoteManager', 'KuzuDBManager']:
56
+ """
57
+ Factory function to get the appropriate database manager based on configuration.
58
+
59
+ Selection logic:
60
+ 1. Runtime override: ``CGC_RUNTIME_DB_TYPE`` (CLI ``--database``, MCP context).
61
+ 2. Configured default: ``DEFAULT_DATABASE`` (``cgc config db …``, CodeGraphContext ``.env``).
62
+ 3. Implicit: ``FALKORDB_HOST`` → remote FalkorDB; else Unix → FalkorDB Lite when available,
63
+ then KùzuDB; Windows → KùzuDB first; Neo4j if configured.
64
+ """
65
+ from codegraphcontext.utils.debug_log import info_logger
66
+
67
+ db_type = os.getenv("CGC_RUNTIME_DB_TYPE") or os.getenv("DEFAULT_DATABASE")
68
+
69
+ if db_type:
70
+ db_type = db_type.lower()
71
+ if db_type == 'kuzudb':
72
+ if not _is_kuzudb_available():
73
+ raise ValueError("Database set to 'kuzudb' but Kùzu is not installed.\nRun 'pip install kuzu'")
74
+ from .database_kuzu import KuzuDBManager
75
+ info_logger(f"Using KùzuDB (explicit) at {db_path or 'default path'}")
76
+ return KuzuDBManager(db_path=db_path)
77
+
78
+ elif db_type == 'falkordb':
79
+ if not _is_falkordb_available():
80
+ info_logger("FalkorDB Lite is not supported or not installed. Falling back to KùzuDB.")
81
+ if _is_kuzudb_available():
82
+ from .database_kuzu import KuzuDBManager
83
+ return KuzuDBManager()
84
+ raise ValueError("Database set to 'falkordb' but FalkorDB Lite is not installed or not supported on this OS.\nRun 'pip install falkordblite'")
85
+
86
+ from .database_falkordb import FalkorDBManager, FalkorDBUnavailableError
87
+ try:
88
+ mgr = FalkorDBManager(db_path=db_path)
89
+ info_logger(f"Using FalkorDB Lite (explicit) at {db_path or 'default path'}")
90
+ return mgr
91
+ except FalkorDBUnavailableError as falkor_err:
92
+ info_logger(f"FalkorDB Lite not functional ({falkor_err}). Falling back to KùzuDB.")
93
+ if _is_kuzudb_available():
94
+ from .database_kuzu import KuzuDBManager
95
+ return KuzuDBManager(db_path=db_path)
96
+ raise
97
+
98
+ elif db_type == 'falkordb-remote':
99
+ if not _is_falkordb_remote_configured():
100
+ raise ValueError(
101
+ "Database set to 'falkordb-remote' but FALKORDB_HOST is not set.\n"
102
+ "Set the FALKORDB_HOST environment variable to your remote FalkorDB host."
103
+ )
104
+ from .database_falkordb_remote import FalkorDBRemoteManager
105
+ info_logger("Using remote FalkorDB (explicit)")
106
+ return FalkorDBRemoteManager()
107
+
108
+ elif db_type == 'neo4j':
109
+ if not _is_neo4j_configured():
110
+ raise ValueError("Database set to 'neo4j' but it is not configured.\nRun 'cgc neo4j setup' to configure Neo4j.")
111
+ from .database import DatabaseManager
112
+ info_logger("Using Neo4j Server (explicit)")
113
+ return DatabaseManager()
114
+ else:
115
+ raise ValueError(f"Unknown database type: '{db_type}'. Use 'kuzudb', 'falkordb', 'falkordb-remote', or 'neo4j'.")
116
+
117
+ # Implicit: remote FalkorDB when FALKORDB_HOST is set (explicit infra signal)
118
+ if _is_falkordb_remote_configured():
119
+ from .database_falkordb_remote import FalkorDBRemoteManager
120
+ info_logger("Using remote FalkorDB (auto-detected via FALKORDB_HOST)")
121
+ return FalkorDBRemoteManager()
122
+
123
+ # Implicit: FalkorDB Lite on Unix when available (typical embedded default there)
124
+ if _is_falkordb_available():
125
+ from .database_falkordb import FalkorDBManager, FalkorDBUnavailableError
126
+ try:
127
+ mgr = FalkorDBManager(db_path=db_path)
128
+ info_logger(f"Using FalkorDB Lite (default) at {db_path or 'default path'}")
129
+ return mgr
130
+ except FalkorDBUnavailableError as falkor_err:
131
+ info_logger(
132
+ f"FalkorDB Lite not functional in this environment ({falkor_err}). "
133
+ "Falling back to KùzuDB."
134
+ )
135
+ # fall through to KùzuDB below
136
+
137
+ # Implicit: KùzuDB (typical on Windows; Unix fallback when Falkor Lite unavailable)
138
+ if _is_kuzudb_available():
139
+ from .database_kuzu import KuzuDBManager
140
+ info_logger(f"Using KùzuDB (default) at {db_path or 'default path'}")
141
+ return KuzuDBManager(db_path=db_path)
142
+
143
+ # Implicit: Neo4j when configured
144
+ if _is_neo4j_configured():
145
+ from .database import DatabaseManager
146
+ info_logger("Using Neo4j Server (auto-detected)")
147
+ return DatabaseManager()
148
+
149
+ error_msg = "No database backend available.\n"
150
+ error_msg += "Recommended: Install KùzuDB for zero-config ('pip install kuzu')\n"
151
+
152
+ if platform.system() != "Windows":
153
+ error_msg += "Alternative: Install FalkorDB Lite ('pip install falkordblite')\n"
154
+
155
+ error_msg += "Alternative: Run 'cgc neo4j setup' to configure Neo4j."
156
+
157
+ raise ValueError(error_msg)
158
+
159
+ # For backward compatibility, export managers
160
+ from .database import DatabaseManager
161
+ from .database_falkordb import FalkorDBManager
162
+ from .database_falkordb_remote import FalkorDBRemoteManager
163
+ from .database_kuzu import KuzuDBManager
164
+
165
+ __all__ = ['DatabaseManager', 'FalkorDBManager', 'FalkorDBRemoteManager', 'KuzuDBManager', 'get_database_manager']
@@ -0,0 +1,181 @@
1
+ import requests
2
+ from pathlib import Path
3
+ from typing import Optional, List, Dict, Any, Tuple
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ def _github_headers() -> dict:
10
+ """Return GitHub API headers, including auth token if available."""
11
+ import os
12
+ headers = {"Accept": "application/vnd.github.v3+json"}
13
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
14
+ if token:
15
+ headers["Authorization"] = f"token {token}"
16
+ return headers
17
+
18
+ GITHUB_ORG = "CodeGraphContext"
19
+ GITHUB_REPO = "CodeGraphContext"
20
+ REGISTRY_API_URL = f"https://api.github.com/repos/{GITHUB_ORG}/{GITHUB_REPO}/releases"
21
+ MANIFEST_URL = f"https://github.com/{GITHUB_ORG}/{GITHUB_REPO}/releases/download/on-demand-bundles/manifest.json"
22
+
23
+ class BundleRegistry:
24
+ """
25
+ Core logic for interacting with the CodeGraphContext bundle registry.
26
+ Handles fetching metadata, searching, and downloading bundles without CLI dependencies.
27
+ """
28
+
29
+ @staticmethod
30
+ def fetch_available_bundles() -> List[Dict[str, Any]]:
31
+ """
32
+ Fetch all available bundles from GitHub Releases and the on-demand manifest.
33
+ Returns a list of bundle dictionaries with metadata.
34
+ Preserves all versions - no deduplication.
35
+ """
36
+ all_bundles = []
37
+
38
+ # 1. Fetch on-demand bundles from manifest
39
+ try:
40
+ response = requests.get(MANIFEST_URL, headers=_github_headers(), timeout=10)
41
+ if response.status_code == 200:
42
+ manifest = response.json()
43
+ if manifest.get('bundles'):
44
+ for bundle in manifest['bundles']:
45
+ bundle['source'] = 'on-demand'
46
+ # Ensure bundle has a full_name field (with version info)
47
+ if 'bundle_name' in bundle:
48
+ # Extract full name without .cgc extension
49
+ bundle['full_name'] = bundle['bundle_name'].replace('.cgc', '')
50
+ all_bundles.append(bundle)
51
+ except Exception as e:
52
+ logger.warning(f"Could not fetch on-demand bundles from manifest: {e}")
53
+
54
+ # 2. Fetch weekly pre-indexed bundles
55
+ try:
56
+ response = requests.get(REGISTRY_API_URL, headers=_github_headers(), timeout=10)
57
+ if response.status_code == 200:
58
+ releases = response.json()
59
+
60
+ # Find weekly releases (bundles-YYYYMMDD pattern)
61
+ weekly_releases = [r for r in releases if r['tag_name'].startswith('bundles-') and r['tag_name'] != 'bundles-latest']
62
+
63
+ if weekly_releases:
64
+ # Get the most recent weekly release
65
+ latest_weekly = weekly_releases[0]
66
+
67
+ for asset in latest_weekly.get('assets', []):
68
+ if asset['name'].endswith('.cgc'):
69
+ # Full bundle name without extension
70
+ full_name = asset['name'].replace('.cgc', '')
71
+
72
+ # Parse bundle name
73
+ name_parts = full_name.split('-')
74
+ bundle = {
75
+ 'name': name_parts[0], # Base package name
76
+ 'full_name': full_name, # Complete name with version
77
+ 'repo': f"{name_parts[0]}/{name_parts[0]}", # Simplified
78
+ 'bundle_name': asset['name'],
79
+ 'version': name_parts[1] if len(name_parts) > 1 else 'latest',
80
+ 'commit': name_parts[2] if len(name_parts) > 2 else 'unknown',
81
+ 'size_bytes': asset.get('size', 0),
82
+ 'size': f"{asset['size'] / 1024 / 1024:.1f}MB",
83
+ 'download_url': asset['browser_download_url'],
84
+ 'generated_at': asset['updated_at'],
85
+ 'source': 'weekly'
86
+ }
87
+ all_bundles.append(bundle)
88
+ except Exception as e:
89
+ logger.warning(f"Could not fetch weekly bundles from GitHub API: {e}")
90
+
91
+ # Normalize all bundles to have required fields
92
+ for bundle in all_bundles:
93
+ # Ensure 'name' field exists (base package name)
94
+ if 'name' not in bundle:
95
+ repo = bundle.get('repo', '')
96
+ if '/' in repo:
97
+ bundle['name'] = repo.split('/')[-1]
98
+ else:
99
+ # Extract from full_name or bundle_name
100
+ full_name = bundle.get('full_name', bundle.get('bundle_name', 'unknown'))
101
+ bundle['name'] = full_name.split('-')[0]
102
+
103
+ # Ensure 'full_name' exists
104
+ if 'full_name' not in bundle:
105
+ bundle['full_name'] = bundle.get('bundle_name', bundle.get('name', 'unknown')).replace('.cgc', '')
106
+
107
+ return all_bundles
108
+
109
+ @staticmethod
110
+ def find_bundle_download_info(name: str) -> Tuple[Optional[str], Optional[Dict[str, Any]], str]:
111
+ """
112
+ Find a download URL and metadata for a bundle by name.
113
+
114
+ Strategies:
115
+ 1. Exact match on full_name (e.g., 'flask-main-abc123')
116
+ 2. Match on base name (e.g., 'flask') - returns most recent version
117
+
118
+ Returns:
119
+ (download_url, bundle_metadata, error_message)
120
+ """
121
+ bundles = BundleRegistry.fetch_available_bundles()
122
+
123
+ if not bundles:
124
+ return None, None, "Could not fetch bundle registry."
125
+
126
+ name_lower = name.lower()
127
+
128
+ # Strategy 1: Exact match on full_name
129
+ for b in bundles:
130
+ if b.get('full_name', '').lower() == name_lower:
131
+ url = b.get('download_url')
132
+ if url:
133
+ return url, b, ""
134
+ return None, b, f"No download URL found for bundle '{name}'"
135
+
136
+ # Strategy 2: Match base package name (most recent)
137
+ matching_bundles = []
138
+ for b in bundles:
139
+ if b.get('name', '').lower() == name_lower:
140
+ matching_bundles.append(b)
141
+
142
+ if matching_bundles:
143
+ # Sort by timestamp (newest first)
144
+ matching_bundles.sort(key=lambda x: x.get('generated_at', ''), reverse=True)
145
+ bundle = matching_bundles[0]
146
+ url = bundle.get('download_url')
147
+ if url:
148
+ return url, bundle, ""
149
+ return None, bundle, f"No download URL found for bundle '{name}'"
150
+
151
+ return None, None, f"Bundle '{name}' not found in registry."
152
+
153
+ @staticmethod
154
+ def download_file(url: str, output_path: Path, progress_callback=None) -> bool:
155
+ """
156
+ Download a file from a URL to a local path.
157
+
158
+ Args:
159
+ url: The URL to download from
160
+ output_path: Local path to save the file
161
+ progress_callback: Optional callable(chunk_size) to report progress
162
+
163
+ Returns:
164
+ True if successful, raises exception otherwise
165
+ """
166
+ try:
167
+ response = requests.get(url, stream=True, timeout=30)
168
+ response.raise_for_status()
169
+
170
+ with open(output_path, 'wb') as f:
171
+ for chunk in response.iter_content(chunk_size=8192):
172
+ if chunk:
173
+ f.write(chunk)
174
+ if progress_callback:
175
+ progress_callback(len(chunk))
176
+ return True
177
+ except Exception as e:
178
+ # Clean up partial file
179
+ if output_path.exists():
180
+ output_path.unlink()
181
+ raise e