edsl 0.1.48__py3-none-any.whl → 0.1.50__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 (239) hide show
  1. edsl/__init__.py +124 -53
  2. edsl/__version__.py +1 -1
  3. edsl/agents/agent.py +21 -21
  4. edsl/agents/agent_list.py +2 -5
  5. edsl/agents/exceptions.py +119 -5
  6. edsl/base/__init__.py +10 -35
  7. edsl/base/base_class.py +71 -36
  8. edsl/base/base_exception.py +204 -0
  9. edsl/base/data_transfer_models.py +1 -1
  10. edsl/base/exceptions.py +94 -0
  11. edsl/buckets/__init__.py +15 -1
  12. edsl/buckets/bucket_collection.py +3 -4
  13. edsl/buckets/exceptions.py +75 -0
  14. edsl/buckets/model_buckets.py +1 -2
  15. edsl/buckets/token_bucket.py +11 -6
  16. edsl/buckets/token_bucket_api.py +1 -2
  17. edsl/buckets/token_bucket_client.py +9 -7
  18. edsl/caching/cache.py +7 -2
  19. edsl/caching/cache_entry.py +10 -9
  20. edsl/caching/exceptions.py +113 -7
  21. edsl/caching/remote_cache_sync.py +1 -2
  22. edsl/caching/sql_dict.py +17 -12
  23. edsl/cli.py +43 -0
  24. edsl/config/config_class.py +30 -6
  25. edsl/conversation/Conversation.py +3 -2
  26. edsl/conversation/exceptions.py +58 -0
  27. edsl/conversation/mug_negotiation.py +0 -2
  28. edsl/coop/__init__.py +20 -1
  29. edsl/coop/coop.py +129 -38
  30. edsl/coop/exceptions.py +188 -9
  31. edsl/coop/price_fetcher.py +3 -6
  32. edsl/coop/utils.py +4 -6
  33. edsl/dataset/__init__.py +5 -4
  34. edsl/dataset/dataset.py +53 -43
  35. edsl/dataset/dataset_operations_mixin.py +86 -72
  36. edsl/dataset/dataset_tree.py +9 -5
  37. edsl/dataset/display/table_display.py +0 -2
  38. edsl/dataset/display/table_renderers.py +0 -1
  39. edsl/dataset/exceptions.py +125 -0
  40. edsl/dataset/file_exports.py +18 -11
  41. edsl/dataset/r/ggplot.py +13 -6
  42. edsl/display/__init__.py +27 -0
  43. edsl/display/core.py +147 -0
  44. edsl/display/plugin.py +189 -0
  45. edsl/display/utils.py +52 -0
  46. edsl/inference_services/__init__.py +9 -1
  47. edsl/inference_services/available_model_cache_handler.py +1 -1
  48. edsl/inference_services/available_model_fetcher.py +4 -5
  49. edsl/inference_services/data_structures.py +9 -6
  50. edsl/inference_services/exceptions.py +132 -1
  51. edsl/inference_services/inference_service_abc.py +2 -2
  52. edsl/inference_services/inference_services_collection.py +2 -6
  53. edsl/inference_services/registry.py +4 -3
  54. edsl/inference_services/service_availability.py +2 -1
  55. edsl/inference_services/services/anthropic_service.py +4 -1
  56. edsl/inference_services/services/aws_bedrock.py +13 -12
  57. edsl/inference_services/services/azure_ai.py +12 -10
  58. edsl/inference_services/services/deep_infra_service.py +1 -4
  59. edsl/inference_services/services/deep_seek_service.py +1 -5
  60. edsl/inference_services/services/google_service.py +6 -2
  61. edsl/inference_services/services/groq_service.py +1 -1
  62. edsl/inference_services/services/mistral_ai_service.py +4 -2
  63. edsl/inference_services/services/ollama_service.py +1 -1
  64. edsl/inference_services/services/open_ai_service.py +7 -5
  65. edsl/inference_services/services/perplexity_service.py +6 -2
  66. edsl/inference_services/services/test_service.py +8 -7
  67. edsl/inference_services/services/together_ai_service.py +2 -3
  68. edsl/inference_services/services/xai_service.py +1 -1
  69. edsl/instructions/__init__.py +1 -1
  70. edsl/instructions/change_instruction.py +3 -2
  71. edsl/instructions/exceptions.py +61 -0
  72. edsl/instructions/instruction.py +5 -2
  73. edsl/instructions/instruction_collection.py +2 -1
  74. edsl/instructions/instruction_handler.py +4 -9
  75. edsl/interviews/ReportErrors.py +0 -3
  76. edsl/interviews/__init__.py +9 -2
  77. edsl/interviews/answering_function.py +11 -13
  78. edsl/interviews/exception_tracking.py +14 -7
  79. edsl/interviews/exceptions.py +79 -0
  80. edsl/interviews/interview.py +32 -29
  81. edsl/interviews/interview_status_dictionary.py +4 -2
  82. edsl/interviews/interview_status_log.py +2 -1
  83. edsl/interviews/interview_task_manager.py +3 -3
  84. edsl/interviews/request_token_estimator.py +3 -1
  85. edsl/interviews/statistics.py +2 -3
  86. edsl/invigilators/__init__.py +7 -1
  87. edsl/invigilators/exceptions.py +79 -0
  88. edsl/invigilators/invigilator_base.py +0 -1
  89. edsl/invigilators/invigilators.py +8 -12
  90. edsl/invigilators/prompt_constructor.py +1 -5
  91. edsl/invigilators/prompt_helpers.py +8 -4
  92. edsl/invigilators/question_instructions_prompt_builder.py +1 -1
  93. edsl/invigilators/question_option_processor.py +9 -5
  94. edsl/invigilators/question_template_replacements_builder.py +3 -2
  95. edsl/jobs/__init__.py +3 -3
  96. edsl/jobs/async_interview_runner.py +24 -22
  97. edsl/jobs/check_survey_scenario_compatibility.py +7 -6
  98. edsl/jobs/data_structures.py +7 -4
  99. edsl/jobs/exceptions.py +177 -8
  100. edsl/jobs/fetch_invigilator.py +1 -1
  101. edsl/jobs/jobs.py +72 -67
  102. edsl/jobs/jobs_checks.py +2 -3
  103. edsl/jobs/jobs_component_constructor.py +2 -2
  104. edsl/jobs/jobs_pricing_estimation.py +3 -2
  105. edsl/jobs/jobs_remote_inference_logger.py +5 -4
  106. edsl/jobs/jobs_runner_asyncio.py +1 -2
  107. edsl/jobs/jobs_runner_status.py +8 -9
  108. edsl/jobs/remote_inference.py +26 -23
  109. edsl/jobs/results_exceptions_handler.py +8 -5
  110. edsl/key_management/__init__.py +3 -1
  111. edsl/key_management/exceptions.py +62 -0
  112. edsl/key_management/key_lookup.py +1 -1
  113. edsl/key_management/key_lookup_builder.py +37 -14
  114. edsl/key_management/key_lookup_collection.py +2 -0
  115. edsl/language_models/__init__.py +1 -1
  116. edsl/language_models/exceptions.py +302 -14
  117. edsl/language_models/language_model.py +4 -7
  118. edsl/language_models/model.py +4 -4
  119. edsl/language_models/model_list.py +1 -1
  120. edsl/language_models/price_manager.py +1 -1
  121. edsl/language_models/raw_response_handler.py +14 -9
  122. edsl/language_models/registry.py +17 -21
  123. edsl/language_models/repair.py +0 -6
  124. edsl/language_models/unused/fake_openai_service.py +0 -1
  125. edsl/load_plugins.py +69 -0
  126. edsl/logger.py +146 -0
  127. edsl/notebooks/notebook.py +1 -1
  128. edsl/notebooks/notebook_to_latex.py +0 -1
  129. edsl/plugins/__init__.py +63 -0
  130. edsl/plugins/built_in/export_example.py +50 -0
  131. edsl/plugins/built_in/pig_latin.py +67 -0
  132. edsl/plugins/cli.py +372 -0
  133. edsl/plugins/cli_typer.py +283 -0
  134. edsl/plugins/exceptions.py +31 -0
  135. edsl/plugins/hookspec.py +51 -0
  136. edsl/plugins/plugin_host.py +128 -0
  137. edsl/plugins/plugin_manager.py +633 -0
  138. edsl/plugins/plugins_registry.py +168 -0
  139. edsl/prompts/__init__.py +2 -0
  140. edsl/prompts/exceptions.py +107 -5
  141. edsl/prompts/prompt.py +14 -6
  142. edsl/questions/HTMLQuestion.py +5 -11
  143. edsl/questions/Quick.py +0 -1
  144. edsl/questions/__init__.py +2 -0
  145. edsl/questions/answer_validator_mixin.py +318 -318
  146. edsl/questions/compose_questions.py +2 -2
  147. edsl/questions/descriptors.py +10 -49
  148. edsl/questions/exceptions.py +278 -22
  149. edsl/questions/loop_processor.py +7 -5
  150. edsl/questions/prompt_templates/question_list.jinja +3 -0
  151. edsl/questions/question_base.py +14 -16
  152. edsl/questions/question_base_gen_mixin.py +2 -2
  153. edsl/questions/question_base_prompts_mixin.py +9 -3
  154. edsl/questions/question_budget.py +9 -5
  155. edsl/questions/question_check_box.py +3 -5
  156. edsl/questions/question_dict.py +171 -194
  157. edsl/questions/question_extract.py +1 -1
  158. edsl/questions/question_free_text.py +4 -6
  159. edsl/questions/question_functional.py +4 -3
  160. edsl/questions/question_list.py +36 -9
  161. edsl/questions/question_matrix.py +95 -61
  162. edsl/questions/question_multiple_choice.py +6 -4
  163. edsl/questions/question_numerical.py +2 -4
  164. edsl/questions/question_registry.py +4 -2
  165. edsl/questions/register_questions_meta.py +0 -1
  166. edsl/questions/response_validator_abc.py +7 -13
  167. edsl/questions/templates/dict/answering_instructions.jinja +1 -0
  168. edsl/questions/templates/rank/question_presentation.jinja +1 -1
  169. edsl/results/__init__.py +1 -1
  170. edsl/results/exceptions.py +141 -7
  171. edsl/results/report.py +0 -1
  172. edsl/results/result.py +4 -5
  173. edsl/results/results.py +10 -51
  174. edsl/results/results_selector.py +8 -4
  175. edsl/scenarios/PdfExtractor.py +2 -2
  176. edsl/scenarios/construct_download_link.py +69 -35
  177. edsl/scenarios/directory_scanner.py +33 -14
  178. edsl/scenarios/document_chunker.py +1 -1
  179. edsl/scenarios/exceptions.py +238 -14
  180. edsl/scenarios/file_methods.py +1 -1
  181. edsl/scenarios/file_store.py +7 -3
  182. edsl/scenarios/handlers/__init__.py +17 -0
  183. edsl/scenarios/handlers/docx_file_store.py +0 -5
  184. edsl/scenarios/handlers/pdf_file_store.py +0 -1
  185. edsl/scenarios/handlers/pptx_file_store.py +0 -5
  186. edsl/scenarios/handlers/py_file_store.py +0 -1
  187. edsl/scenarios/handlers/sql_file_store.py +1 -4
  188. edsl/scenarios/handlers/sqlite_file_store.py +0 -1
  189. edsl/scenarios/handlers/txt_file_store.py +1 -1
  190. edsl/scenarios/scenario.py +0 -1
  191. edsl/scenarios/scenario_list.py +152 -18
  192. edsl/scenarios/scenario_list_pdf_tools.py +1 -0
  193. edsl/scenarios/scenario_selector.py +0 -1
  194. edsl/surveys/__init__.py +3 -4
  195. edsl/surveys/dag/__init__.py +4 -2
  196. edsl/surveys/descriptors.py +1 -1
  197. edsl/surveys/edit_survey.py +1 -0
  198. edsl/surveys/exceptions.py +165 -9
  199. edsl/surveys/memory/__init__.py +5 -3
  200. edsl/surveys/memory/memory_management.py +1 -0
  201. edsl/surveys/memory/memory_plan.py +6 -15
  202. edsl/surveys/rules/__init__.py +5 -3
  203. edsl/surveys/rules/rule.py +1 -2
  204. edsl/surveys/rules/rule_collection.py +1 -1
  205. edsl/surveys/survey.py +12 -24
  206. edsl/surveys/survey_export.py +6 -3
  207. edsl/surveys/survey_flow_visualization.py +10 -1
  208. edsl/tasks/__init__.py +2 -0
  209. edsl/tasks/question_task_creator.py +3 -3
  210. edsl/tasks/task_creators.py +1 -3
  211. edsl/tasks/task_history.py +5 -7
  212. edsl/tasks/task_status_log.py +1 -2
  213. edsl/tokens/__init__.py +3 -1
  214. edsl/tokens/token_usage.py +1 -1
  215. edsl/utilities/__init__.py +21 -1
  216. edsl/utilities/decorators.py +1 -2
  217. edsl/utilities/markdown_to_docx.py +2 -2
  218. edsl/utilities/markdown_to_pdf.py +1 -1
  219. edsl/utilities/repair_functions.py +0 -1
  220. edsl/utilities/restricted_python.py +0 -1
  221. edsl/utilities/template_loader.py +2 -3
  222. edsl/utilities/utilities.py +8 -29
  223. {edsl-0.1.48.dist-info → edsl-0.1.50.dist-info}/METADATA +32 -2
  224. edsl-0.1.50.dist-info/RECORD +363 -0
  225. edsl-0.1.50.dist-info/entry_points.txt +3 -0
  226. edsl/dataset/smart_objects.py +0 -96
  227. edsl/exceptions/BaseException.py +0 -21
  228. edsl/exceptions/__init__.py +0 -54
  229. edsl/exceptions/configuration.py +0 -16
  230. edsl/exceptions/general.py +0 -34
  231. edsl/study/ObjectEntry.py +0 -173
  232. edsl/study/ProofOfWork.py +0 -113
  233. edsl/study/SnapShot.py +0 -80
  234. edsl/study/Study.py +0 -520
  235. edsl/study/__init__.py +0 -6
  236. edsl/utilities/interface.py +0 -135
  237. edsl-0.1.48.dist-info/RECORD +0 -347
  238. {edsl-0.1.48.dist-info → edsl-0.1.50.dist-info}/LICENSE +0 -0
  239. {edsl-0.1.48.dist-info → edsl-0.1.50.dist-info}/WHEEL +0 -0
@@ -0,0 +1,633 @@
1
+ # edsl/plugins/plugin_manager.py
2
+ import os
3
+ import sys
4
+ import subprocess
5
+ import tempfile
6
+ import importlib
7
+ import importlib.util
8
+ import shutil
9
+ import re
10
+ import json
11
+ from typing import Optional, List, Dict, Any
12
+ import pluggy
13
+ import platformdirs
14
+ from urllib.parse import urlparse
15
+
16
+ from .hookspec import EDSLPluginSpec
17
+ from .exceptions import (
18
+ PluginException,
19
+ PluginNotFoundError,
20
+ PluginInstallationError,
21
+ GitHubRepoError,
22
+ InvalidPluginError,
23
+ PluginDependencyError,
24
+ PluginMethodError
25
+ )
26
+ from .. import logger
27
+
28
+ class EDSLPluginManager:
29
+ """Manage EDSL plugins using pluggy."""
30
+
31
+ # Define paths for persistent data
32
+ PLUGINS_DATA_DIR = platformdirs.user_data_dir("edsl")
33
+ PLUGINS_DATA_FILE = os.path.join(PLUGINS_DATA_DIR, "installed_plugins.json")
34
+
35
+ # Ensure the directory exists
36
+ os.makedirs(PLUGINS_DATA_DIR, exist_ok=True)
37
+
38
+ def __init__(self):
39
+ # Create a plugin manager for the "edsl" project
40
+ self.manager = pluggy.PluginManager("edsl")
41
+ # Register the hook specifications
42
+ self.manager.add_hookspecs(EDSLPluginSpec)
43
+ # Load all plugins that are installed
44
+ self.manager.load_setuptools_entrypoints("edsl_plugins")
45
+ # Dictionary to store plugin methods
46
+ self.methods = {}
47
+ # Dictionary to track installed plugins
48
+ self.installed_plugins = self._load_installed_plugins()
49
+ # Dictionary to store objects exported to the global namespace
50
+ self.exports = {}
51
+ # Register built-in plugins
52
+ self._register_builtin_plugins()
53
+ # Discover and register methods
54
+ self._discover_methods()
55
+ # Gather exports from plugins
56
+ self._gather_exports()
57
+
58
+ def _load_installed_plugins(self) -> Dict[str, str]:
59
+ """
60
+ Load the list of installed plugins from the data file.
61
+
62
+ Returns:
63
+ Dictionary mapping plugin names to installation directories
64
+ """
65
+ # Create the directory if it doesn't exist
66
+ os.makedirs(os.path.dirname(self.PLUGINS_DATA_FILE), exist_ok=True)
67
+
68
+ # Load the plugins data if it exists
69
+ if os.path.exists(self.PLUGINS_DATA_FILE):
70
+ try:
71
+ with open(self.PLUGINS_DATA_FILE, 'r') as f:
72
+ return json.load(f)
73
+ except (json.JSONDecodeError, IOError) as e:
74
+ logger.warning(f"Failed to load installed plugins data: {str(e)}")
75
+
76
+ return {}
77
+
78
+ def _save_installed_plugins(self):
79
+ """
80
+ Save the list of installed plugins to the data file.
81
+ """
82
+ try:
83
+ with open(self.PLUGINS_DATA_FILE, 'w') as f:
84
+ json.dump(self.installed_plugins, f)
85
+ except IOError as e:
86
+ logger.warning(f"Failed to save installed plugins data: {str(e)}")
87
+
88
+ def install_plugin_from_github(self, github_url: str, branch: Optional[str] = None) -> List[str]:
89
+ """
90
+ Install a plugin from a GitHub repository.
91
+
92
+ Args:
93
+ github_url: URL to the GitHub repository
94
+ branch: Optional branch to checkout (defaults to main/master)
95
+
96
+ Returns:
97
+ List of installed plugin names
98
+
99
+ Raises:
100
+ GitHubRepoError: If the URL is invalid or the repository cannot be accessed
101
+ PluginInstallationError: If the installation fails
102
+ InvalidPluginError: If the repository does not contain valid plugins
103
+ """
104
+ # Validate GitHub URL
105
+ self._validate_github_url(github_url)
106
+
107
+ # Extract plugin name from URL
108
+ repo_name = github_url.split('/')[-1]
109
+ if repo_name.endswith('.git'):
110
+ repo_name = repo_name[:-4]
111
+
112
+ # Create a persistent directory for the plugin
113
+ plugins_install_dir = os.path.join(self.PLUGINS_DATA_DIR, "plugins")
114
+ os.makedirs(plugins_install_dir, exist_ok=True)
115
+ plugin_dir = os.path.join(plugins_install_dir, repo_name)
116
+
117
+ # Remove existing installation if it exists
118
+ if os.path.exists(plugin_dir):
119
+ shutil.rmtree(plugin_dir)
120
+
121
+ try:
122
+ # Clone the repository to the persistent directory
123
+ self._clone_repository(github_url, plugin_dir, branch)
124
+
125
+ # Check for setup.py or pyproject.toml
126
+ if not self._has_package_files(plugin_dir):
127
+ raise InvalidPluginError(
128
+ f"Repository at {github_url} does not contain required setup.py or pyproject.toml"
129
+ )
130
+
131
+ # Install the package in development mode
132
+ installed_plugins = self._install_package(plugin_dir)
133
+
134
+ # Reload plugins and discover methods
135
+ self._reload_plugins()
136
+
137
+ return installed_plugins
138
+
139
+ except GitHubRepoError as e:
140
+ # Re-raise with more context
141
+ raise GitHubRepoError(f"Failed to access GitHub repository: {str(e)}")
142
+ except subprocess.CalledProcessError as e:
143
+ raise PluginInstallationError(f"Installation command failed: {e.output.decode() if hasattr(e, 'output') else str(e)}")
144
+ except Exception as e:
145
+ raise PluginInstallationError(f"Plugin installation failed: {str(e)}")
146
+
147
+ def _validate_github_url(self, url: str) -> None:
148
+ """
149
+ Validate that a URL is a valid GitHub repository URL.
150
+
151
+ Args:
152
+ url: URL to validate
153
+
154
+ Raises:
155
+ GitHubRepoError: If the URL is invalid
156
+ """
157
+ parsed_url = urlparse(url)
158
+
159
+ # Check that it's a valid URL with https scheme
160
+ if not parsed_url.scheme or not parsed_url.netloc:
161
+ raise GitHubRepoError(f"Invalid URL: {url}")
162
+
163
+ # Check that it's a GitHub URL
164
+ if not parsed_url.netloc.endswith('github.com'):
165
+ raise GitHubRepoError(f"Not a GitHub URL: {url}")
166
+
167
+ # Check that it has a path (username/repo)
168
+ if not parsed_url.path or parsed_url.path.count('/') < 2:
169
+ raise GitHubRepoError(f"Invalid GitHub repository path: {url}")
170
+
171
+ def _clone_repository(self, url: str, target_dir: str, branch: Optional[str] = None) -> None:
172
+ """
173
+ Clone a GitHub repository to a local directory.
174
+
175
+ Args:
176
+ url: URL of the GitHub repository
177
+ target_dir: Directory to clone into
178
+ branch: Optional branch to checkout
179
+
180
+ Raises:
181
+ GitHubRepoError: If the clone fails
182
+ """
183
+ try:
184
+ # Basic clone command
185
+ cmd = ['git', 'clone', url, target_dir]
186
+ subprocess.run(cmd, check=True, capture_output=True)
187
+
188
+ # Checkout specific branch if requested
189
+ if branch:
190
+ subprocess.run(
191
+ ['git', 'checkout', branch],
192
+ check=True,
193
+ capture_output=True,
194
+ cwd=target_dir
195
+ )
196
+ except subprocess.CalledProcessError as e:
197
+ error_msg = e.stderr.decode() if hasattr(e, 'stderr') else str(e)
198
+ raise GitHubRepoError(f"Failed to clone repository: {error_msg}")
199
+
200
+ def _has_package_files(self, directory: str) -> bool:
201
+ """
202
+ Check if a directory contains Python package files.
203
+
204
+ Args:
205
+ directory: Directory to check
206
+
207
+ Returns:
208
+ True if setup.py or pyproject.toml exist
209
+ """
210
+ return (
211
+ os.path.exists(os.path.join(directory, 'setup.py')) or
212
+ os.path.exists(os.path.join(directory, 'pyproject.toml'))
213
+ )
214
+
215
+ def _install_package(self, package_dir: str) -> List[str]:
216
+ """
217
+ Install a Python package from a local directory.
218
+
219
+ Args:
220
+ package_dir: Directory containing the package
221
+
222
+ Returns:
223
+ List of installed plugin names
224
+
225
+ Raises:
226
+ PluginInstallationError: If installation fails
227
+ """
228
+ try:
229
+ # Install in development mode
230
+ cmd = [sys.executable, '-m', 'pip', 'install', '-e', package_dir]
231
+ subprocess.run(cmd, check=True, capture_output=True)
232
+
233
+ # Try to determine the package name from setup.py or pyproject.toml
234
+ package_name = self._get_package_name(package_dir)
235
+
236
+ # Record successful installation
237
+ self.installed_plugins[package_name] = package_dir
238
+ # Save the updated list of installed plugins
239
+ self._save_installed_plugins()
240
+
241
+ # Return names of plugins in this package
242
+ # For now just return the package name as a placeholder
243
+ return [package_name]
244
+
245
+ except subprocess.CalledProcessError as e:
246
+ error_msg = e.stderr.decode() if hasattr(e, 'stderr') else str(e)
247
+ raise PluginInstallationError(f"Failed to install package: {error_msg}")
248
+
249
+ def _get_package_name(self, package_dir: str) -> str:
250
+ """
251
+ Extract the package name from setup.py or pyproject.toml.
252
+
253
+ Args:
254
+ package_dir: Directory containing the package
255
+
256
+ Returns:
257
+ Package name (defaults to directory name if can't be determined)
258
+ """
259
+ # Try to extract from setup.py
260
+ setup_py = os.path.join(package_dir, 'setup.py')
261
+ if os.path.exists(setup_py):
262
+ with open(setup_py, 'r') as f:
263
+ content = f.read()
264
+ # Look for name='...' pattern
265
+ match = re.search(r"name=['\"]([^'\"]+)['\"]", content)
266
+ if match:
267
+ return match.group(1)
268
+
269
+ # Try to extract from pyproject.toml
270
+ pyproject_toml = os.path.join(package_dir, 'pyproject.toml')
271
+ if os.path.exists(pyproject_toml):
272
+ with open(pyproject_toml, 'r') as f:
273
+ content = f.read()
274
+ # Look for name = "..." pattern
275
+ match = re.search(r"name\s*=\s*['\"]([^'\"]+)['\"]", content)
276
+ if match:
277
+ return match.group(1)
278
+
279
+ # Look for name in plugins registry to normalize name
280
+ try:
281
+ # Get the GitHub URL from setup.py or repo directory
282
+ git_config = os.path.join(package_dir, '.git', 'config')
283
+ if os.path.exists(git_config):
284
+ with open(git_config, 'r') as f:
285
+ content = f.read()
286
+ url_match = re.search(r"url\s*=\s*(.+)", content)
287
+ if url_match:
288
+ url = url_match.group(1).strip()
289
+ # Extract repo name
290
+ repo_name = url.split('/')[-1]
291
+ # Remove .git suffix if present
292
+ if repo_name.endswith('.git'):
293
+ repo_name = repo_name[:-4]
294
+
295
+ # If repo name follows common patterns
296
+ if repo_name.startswith('edsl-'):
297
+ return repo_name[5:] # Remove 'edsl-' prefix
298
+ if repo_name.startswith('plugin-'):
299
+ return repo_name[7:] # Remove 'plugin-' prefix
300
+ if repo_name.endswith('-plugin'):
301
+ return repo_name[:-7] # Remove '-plugin' suffix
302
+ except Exception as e:
303
+ logger.debug(f"Error extracting name from git config: {str(e)}")
304
+
305
+ # Default to directory name
306
+ dir_name = os.path.basename(os.path.normpath(package_dir))
307
+ # Clean up common prefixes/suffixes in directory names too
308
+ if dir_name.startswith('edsl-'):
309
+ return dir_name[5:]
310
+ if dir_name.startswith('plugin-'):
311
+ return dir_name[7:]
312
+ if dir_name.endswith('-plugin'):
313
+ return dir_name[:-7]
314
+
315
+ return dir_name
316
+
317
+ def _gather_exports(self):
318
+ """Gather objects from plugins that should be exported to the global namespace."""
319
+ # Clear existing exports
320
+ self.exports = {}
321
+
322
+ # Get all plugins
323
+ for plugin in self.manager.get_plugins():
324
+ try:
325
+ # Check if the plugin implements the exports_to_namespace hook
326
+ if hasattr(plugin, "exports_to_namespace"):
327
+ exports = plugin.exports_to_namespace()
328
+ if exports:
329
+ # If plugin provides exports, add them to the exports dictionary
330
+ plugin_name = plugin.plugin_name()
331
+ for name, obj in exports.items():
332
+ # Log the export
333
+ logger.info(f"Plugin '{plugin_name}' exports '{name}' to global namespace")
334
+ self.exports[name] = obj
335
+ except Exception as e:
336
+ logger.warning(f"Error gathering exports from plugin: {str(e)}")
337
+
338
+ def get_exports(self) -> Dict[str, Any]:
339
+ """
340
+ Get all objects that plugins export to the global namespace.
341
+
342
+ Returns:
343
+ Dictionary mapping names to exported objects
344
+ """
345
+ return self.exports
346
+
347
+ def _reload_plugins(self) -> None:
348
+ """Reload plugins and discover new methods."""
349
+ # Reload setuptools entry points
350
+ self.manager.load_setuptools_entrypoints("edsl_plugins")
351
+
352
+ # Also check for directly installed plugins
353
+ for plugin_name, plugin_dir in self.installed_plugins.items():
354
+ try:
355
+ if os.path.exists(plugin_dir):
356
+ # Try to find the main plugin module
357
+ plugin_module = os.path.join(plugin_dir, plugin_name.lower())
358
+ if os.path.exists(f"{plugin_module}.py"):
359
+ spec = importlib.util.spec_from_file_location(plugin_name, f"{plugin_module}.py")
360
+ if spec:
361
+ module = importlib.util.module_from_spec(spec)
362
+ spec.loader.exec_module(module)
363
+ # Try to register the plugin class
364
+ if hasattr(module, plugin_name):
365
+ plugin_class = getattr(module, plugin_name)
366
+ self.manager.register(plugin_class())
367
+ logger.info(f"Registered plugin {plugin_name} from {plugin_dir}")
368
+ except Exception as e:
369
+ logger.warning(f"Error loading plugin {plugin_name} from {plugin_dir}: {str(e)}")
370
+
371
+ # Rediscover methods
372
+ self._discover_methods()
373
+
374
+ # Gather exports from plugins
375
+ self._gather_exports()
376
+
377
+ def uninstall_plugin(self, plugin_name: str) -> bool:
378
+ """
379
+ Uninstall a plugin by name.
380
+
381
+ Args:
382
+ plugin_name: Name of the plugin to uninstall
383
+
384
+ Returns:
385
+ True if uninstallation was successful
386
+
387
+ Raises:
388
+ PluginNotFoundError: If the plugin is not installed
389
+ PluginInstallationError: If uninstallation fails
390
+ """
391
+ # Check if plugin is installed directly
392
+ if plugin_name in self.installed_plugins:
393
+ actual_name = plugin_name
394
+ else:
395
+ # Try to find the plugin using case-insensitive matching and pattern recognition
396
+ actual_name = None
397
+ for installed_name in self.installed_plugins:
398
+ # Check for exact match ignoring case
399
+ if installed_name.lower() == plugin_name.lower():
400
+ actual_name = installed_name
401
+ break
402
+
403
+ # Check for matches with common prefix/suffix variations
404
+ normalized_installed = installed_name.lower()
405
+ normalized_requested = plugin_name.lower()
406
+
407
+ # Strip common prefixes/suffixes for comparison
408
+ for prefix in ["edsl-", "plugin-"]:
409
+ if normalized_installed.startswith(prefix):
410
+ normalized_installed = normalized_installed[len(prefix):]
411
+ if normalized_requested.startswith(prefix):
412
+ normalized_requested = normalized_requested[len(prefix):]
413
+
414
+ for suffix in ["-plugin"]:
415
+ if normalized_installed.endswith(suffix):
416
+ normalized_installed = normalized_installed[:-len(suffix)]
417
+ if normalized_requested.endswith(suffix):
418
+ normalized_requested = normalized_requested[:-len(suffix)]
419
+
420
+ # Compare normalized names
421
+ if normalized_installed == normalized_requested:
422
+ actual_name = installed_name
423
+ break
424
+
425
+ if actual_name is None:
426
+ raise PluginNotFoundError(f"Plugin '{plugin_name}' is not installed")
427
+
428
+ try:
429
+ package_dir = self.installed_plugins[actual_name]
430
+ logger.debug(f"Uninstalling plugin '{actual_name}' from {package_dir}")
431
+
432
+ # Determine pip package name
433
+ pip_package_name = actual_name
434
+
435
+ # Try to get the actual package name from setup.py or pyproject.toml
436
+ setup_py = os.path.join(package_dir, 'setup.py')
437
+ if os.path.exists(setup_py):
438
+ with open(setup_py, 'r') as f:
439
+ content = f.read()
440
+ match = re.search(r"name=['\"]([^'\"]+)['\"]", content)
441
+ if match:
442
+ pip_package_name = match.group(1)
443
+
444
+ pyproject_toml = os.path.join(package_dir, 'pyproject.toml')
445
+ if os.path.exists(pyproject_toml):
446
+ with open(pyproject_toml, 'r') as f:
447
+ content = f.read()
448
+ match = re.search(r"name\s*=\s*['\"]([^'\"]+)['\"]", content)
449
+ if match:
450
+ pip_package_name = match.group(1)
451
+
452
+ # Uninstall the package using pip
453
+ cmd = [sys.executable, '-m', 'pip', 'uninstall', '-y', pip_package_name]
454
+ subprocess.run(cmd, check=True, capture_output=True)
455
+
456
+ # Remove from installed plugins
457
+ del self.installed_plugins[actual_name]
458
+ # Save the updated list of installed plugins
459
+ self._save_installed_plugins()
460
+
461
+ # Reload plugins
462
+ self._reload_plugins()
463
+
464
+ return True
465
+
466
+ except subprocess.CalledProcessError as e:
467
+ error_msg = e.stderr.decode() if hasattr(e, 'stderr') else str(e)
468
+ raise PluginInstallationError(f"Failed to uninstall plugin: {error_msg}")
469
+
470
+ def _register_builtin_plugins(self):
471
+ """Register built-in plugins."""
472
+ # Import and register internal plugins
473
+ from .built_in.pig_latin import PigLatin
474
+ self.manager.register(PigLatin())
475
+
476
+ # Register the export example plugin
477
+ try:
478
+ from .built_in.export_example import ExportExample
479
+ self.manager.register(ExportExample())
480
+ except ImportError:
481
+ # In case the file isn't there or has issues
482
+ logger.warning("Failed to load export_example plugin")
483
+
484
+ def _discover_methods(self):
485
+ """Discover and register all plugin methods."""
486
+ # Clear existing methods
487
+ self.methods = {}
488
+
489
+ # Get all plugin names
490
+ for plugin in self.manager.get_plugins():
491
+ try:
492
+ # Call the hook method directly on the plugin instance
493
+ plugin_name = plugin.plugin_name()
494
+
495
+ # Get methods from this plugin
496
+ methods = plugin.get_plugin_methods()
497
+
498
+ # Register methods with their full names and shortcuts
499
+ if methods:
500
+ for method_name, method in methods.items():
501
+ # Create a bound method that takes the plugin instance as the first argument
502
+ bound_method = lambda *args, m=method, **kwargs: m(*args, **kwargs)
503
+
504
+ # Full qualified name
505
+ full_name = f"{plugin_name}.{method_name}"
506
+ self.methods[full_name] = bound_method
507
+
508
+ # Register shorthand if not already taken
509
+ if method_name not in self.methods:
510
+ self.methods[method_name] = bound_method
511
+ except Exception as e:
512
+ logger.warning(f"Error discovering methods for plugin: {str(e)}")
513
+
514
+ def get_method(self, name: str) -> Optional[callable]:
515
+ """
516
+ Get a method by name.
517
+
518
+ Args:
519
+ name: Method name, can be shorthand or fully qualified
520
+
521
+ Returns:
522
+ Method function or None if not found
523
+
524
+ Raises:
525
+ PluginMethodError: If the method is not found but a plugin is
526
+ """
527
+ if name in self.methods:
528
+ return self.methods[name]
529
+
530
+ # If the name looks like a fully qualified name (plugin.method)
531
+ # but the plugin exists and the method doesn't, give a more helpful error
532
+ if '.' in name:
533
+ plugin_name = name.split('.')[0]
534
+ for plugin in self.manager.get_plugins():
535
+ if plugin.plugin_name() == plugin_name:
536
+ method_name = name.split('.')[1]
537
+ raise PluginMethodError(
538
+ f"Plugin '{plugin_name}' exists but method '{method_name}' was not found"
539
+ )
540
+
541
+ # Method not found
542
+ return None
543
+
544
+ def list_methods(self) -> List[str]:
545
+ """
546
+ List all available methods.
547
+
548
+ Returns:
549
+ Sorted list of method names
550
+ """
551
+ return sorted(self.methods.keys())
552
+
553
+ def list_plugins(self) -> Dict[str, Dict[str, Any]]:
554
+ """
555
+ List all installed plugins with their details.
556
+
557
+ Returns:
558
+ Dictionary mapping plugin names to details
559
+ """
560
+ plugins_info = {}
561
+
562
+ # Get plugins from the plugin manager
563
+ for plugin in self.manager.get_plugins():
564
+ try:
565
+ name = plugin.plugin_name()
566
+
567
+ # Gather details
568
+ plugin_info = {
569
+ "name": name,
570
+ "description": plugin.plugin_description() if hasattr(plugin, "plugin_description") else None,
571
+ "methods": list(plugin.get_plugin_methods().keys()) if hasattr(plugin, "get_plugin_methods") else [],
572
+ "installed_from": self.installed_plugins.get(name, "built-in")
573
+ }
574
+
575
+ plugins_info[name] = plugin_info
576
+
577
+ except Exception as e:
578
+ logger.warning(f"Error getting info for plugin: {str(e)}")
579
+
580
+ # Also include all plugins from installed_plugins.json even if they're not loaded
581
+ for name, install_dir in self.installed_plugins.items():
582
+ if name not in plugins_info:
583
+ # Try to find description from standard paths
584
+ description = None
585
+ try:
586
+ # First check if we can find a description in setup.py or pyproject.toml
587
+ setup_py = os.path.join(install_dir, 'setup.py')
588
+ if os.path.exists(setup_py):
589
+ with open(setup_py, 'r') as f:
590
+ content = f.read()
591
+ # Look for description="..." pattern
592
+ match = re.search(r"description=['\"]([^'\"]+)['\"]", content)
593
+ if match:
594
+ description = match.group(1)
595
+
596
+ pyproject_toml = os.path.join(install_dir, 'pyproject.toml')
597
+ if not description and os.path.exists(pyproject_toml):
598
+ with open(pyproject_toml, 'r') as f:
599
+ content = f.read()
600
+ # Look for description = "..." pattern
601
+ match = re.search(r"description\s*=\s*['\"]([^'\"]+)['\"]", content)
602
+ if match:
603
+ description = match.group(1)
604
+
605
+ # If still no description, check for README files
606
+ if not description:
607
+ for readme_file in ["README.md", "README.rst", "README.txt"]:
608
+ readme_path = os.path.join(install_dir, readme_file)
609
+ if os.path.exists(readme_path):
610
+ with open(readme_path, 'r') as f:
611
+ # Skip any markdown headers
612
+ first_line = f.readline().strip()
613
+ if first_line.startswith('#'):
614
+ # Take the next line that's not empty
615
+ for line in f:
616
+ if line.strip():
617
+ first_line = line.strip()
618
+ break
619
+ description = first_line[:100] + "..." if len(first_line) > 100 else first_line
620
+ break
621
+ except Exception as e:
622
+ logger.debug(f"Error extracting description for {name}: {e}")
623
+
624
+ # Add basic info for installed but not loaded plugins
625
+ plugins_info[name] = {
626
+ "name": name,
627
+ "description": description or f"Plugin installed at {install_dir}",
628
+ "methods": [],
629
+ "installed_from": install_dir,
630
+ "version": "unknown"
631
+ }
632
+
633
+ return plugins_info