edsl 0.1.38.dev4__py3-none-any.whl → 0.1.39__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 (212) hide show
  1. edsl/Base.py +197 -116
  2. edsl/__init__.py +15 -7
  3. edsl/__version__.py +1 -1
  4. edsl/agents/Agent.py +351 -147
  5. edsl/agents/AgentList.py +211 -73
  6. edsl/agents/Invigilator.py +101 -50
  7. edsl/agents/InvigilatorBase.py +62 -70
  8. edsl/agents/PromptConstructor.py +143 -225
  9. edsl/agents/QuestionInstructionPromptBuilder.py +128 -0
  10. edsl/agents/QuestionTemplateReplacementsBuilder.py +137 -0
  11. edsl/agents/__init__.py +0 -1
  12. edsl/agents/prompt_helpers.py +3 -3
  13. edsl/agents/question_option_processor.py +172 -0
  14. edsl/auto/AutoStudy.py +18 -5
  15. edsl/auto/StageBase.py +53 -40
  16. edsl/auto/StageQuestions.py +2 -1
  17. edsl/auto/utilities.py +0 -6
  18. edsl/config.py +22 -2
  19. edsl/conversation/car_buying.py +2 -1
  20. edsl/coop/CoopFunctionsMixin.py +15 -0
  21. edsl/coop/ExpectedParrotKeyHandler.py +125 -0
  22. edsl/coop/PriceFetcher.py +1 -1
  23. edsl/coop/coop.py +125 -47
  24. edsl/coop/utils.py +14 -14
  25. edsl/data/Cache.py +45 -27
  26. edsl/data/CacheEntry.py +12 -15
  27. edsl/data/CacheHandler.py +31 -12
  28. edsl/data/RemoteCacheSync.py +154 -46
  29. edsl/data/__init__.py +4 -3
  30. edsl/data_transfer_models.py +2 -1
  31. edsl/enums.py +27 -0
  32. edsl/exceptions/__init__.py +50 -50
  33. edsl/exceptions/agents.py +12 -0
  34. edsl/exceptions/inference_services.py +5 -0
  35. edsl/exceptions/questions.py +24 -6
  36. edsl/exceptions/scenarios.py +7 -0
  37. edsl/inference_services/AnthropicService.py +38 -19
  38. edsl/inference_services/AvailableModelCacheHandler.py +184 -0
  39. edsl/inference_services/AvailableModelFetcher.py +215 -0
  40. edsl/inference_services/AwsBedrock.py +0 -2
  41. edsl/inference_services/AzureAI.py +0 -2
  42. edsl/inference_services/GoogleService.py +7 -12
  43. edsl/inference_services/InferenceServiceABC.py +18 -85
  44. edsl/inference_services/InferenceServicesCollection.py +120 -79
  45. edsl/inference_services/MistralAIService.py +0 -3
  46. edsl/inference_services/OpenAIService.py +47 -35
  47. edsl/inference_services/PerplexityService.py +0 -3
  48. edsl/inference_services/ServiceAvailability.py +135 -0
  49. edsl/inference_services/TestService.py +11 -10
  50. edsl/inference_services/TogetherAIService.py +5 -3
  51. edsl/inference_services/data_structures.py +134 -0
  52. edsl/jobs/AnswerQuestionFunctionConstructor.py +223 -0
  53. edsl/jobs/Answers.py +1 -14
  54. edsl/jobs/FetchInvigilator.py +47 -0
  55. edsl/jobs/InterviewTaskManager.py +98 -0
  56. edsl/jobs/InterviewsConstructor.py +50 -0
  57. edsl/jobs/Jobs.py +356 -431
  58. edsl/jobs/JobsChecks.py +35 -10
  59. edsl/jobs/JobsComponentConstructor.py +189 -0
  60. edsl/jobs/JobsPrompts.py +6 -4
  61. edsl/jobs/JobsRemoteInferenceHandler.py +205 -133
  62. edsl/jobs/JobsRemoteInferenceLogger.py +239 -0
  63. edsl/jobs/RequestTokenEstimator.py +30 -0
  64. edsl/jobs/async_interview_runner.py +138 -0
  65. edsl/jobs/buckets/BucketCollection.py +44 -3
  66. edsl/jobs/buckets/TokenBucket.py +53 -21
  67. edsl/jobs/buckets/TokenBucketAPI.py +211 -0
  68. edsl/jobs/buckets/TokenBucketClient.py +191 -0
  69. edsl/jobs/check_survey_scenario_compatibility.py +85 -0
  70. edsl/jobs/data_structures.py +120 -0
  71. edsl/jobs/decorators.py +35 -0
  72. edsl/jobs/interviews/Interview.py +143 -408
  73. edsl/jobs/jobs_status_enums.py +9 -0
  74. edsl/jobs/loggers/HTMLTableJobLogger.py +304 -0
  75. edsl/jobs/results_exceptions_handler.py +98 -0
  76. edsl/jobs/runners/JobsRunnerAsyncio.py +88 -403
  77. edsl/jobs/runners/JobsRunnerStatus.py +133 -165
  78. edsl/jobs/tasks/QuestionTaskCreator.py +21 -19
  79. edsl/jobs/tasks/TaskHistory.py +38 -18
  80. edsl/jobs/tasks/task_status_enum.py +0 -2
  81. edsl/language_models/ComputeCost.py +63 -0
  82. edsl/language_models/LanguageModel.py +194 -236
  83. edsl/language_models/ModelList.py +28 -19
  84. edsl/language_models/PriceManager.py +127 -0
  85. edsl/language_models/RawResponseHandler.py +106 -0
  86. edsl/language_models/ServiceDataSources.py +0 -0
  87. edsl/language_models/__init__.py +1 -2
  88. edsl/language_models/key_management/KeyLookup.py +63 -0
  89. edsl/language_models/key_management/KeyLookupBuilder.py +273 -0
  90. edsl/language_models/key_management/KeyLookupCollection.py +38 -0
  91. edsl/language_models/key_management/__init__.py +0 -0
  92. edsl/language_models/key_management/models.py +131 -0
  93. edsl/language_models/model.py +256 -0
  94. edsl/language_models/repair.py +2 -2
  95. edsl/language_models/utilities.py +5 -4
  96. edsl/notebooks/Notebook.py +19 -14
  97. edsl/notebooks/NotebookToLaTeX.py +142 -0
  98. edsl/prompts/Prompt.py +29 -39
  99. edsl/questions/ExceptionExplainer.py +77 -0
  100. edsl/questions/HTMLQuestion.py +103 -0
  101. edsl/questions/QuestionBase.py +68 -214
  102. edsl/questions/QuestionBasePromptsMixin.py +7 -3
  103. edsl/questions/QuestionBudget.py +1 -1
  104. edsl/questions/QuestionCheckBox.py +3 -3
  105. edsl/questions/QuestionExtract.py +5 -7
  106. edsl/questions/QuestionFreeText.py +2 -3
  107. edsl/questions/QuestionList.py +10 -18
  108. edsl/questions/QuestionMatrix.py +265 -0
  109. edsl/questions/QuestionMultipleChoice.py +67 -23
  110. edsl/questions/QuestionNumerical.py +2 -4
  111. edsl/questions/QuestionRank.py +7 -17
  112. edsl/questions/SimpleAskMixin.py +4 -3
  113. edsl/questions/__init__.py +2 -1
  114. edsl/questions/{AnswerValidatorMixin.py → answer_validator_mixin.py} +47 -2
  115. edsl/questions/data_structures.py +20 -0
  116. edsl/questions/derived/QuestionLinearScale.py +6 -3
  117. edsl/questions/derived/QuestionTopK.py +1 -1
  118. edsl/questions/descriptors.py +17 -3
  119. edsl/questions/loop_processor.py +149 -0
  120. edsl/questions/{QuestionBaseGenMixin.py → question_base_gen_mixin.py} +57 -50
  121. edsl/questions/question_registry.py +1 -1
  122. edsl/questions/{ResponseValidatorABC.py → response_validator_abc.py} +40 -26
  123. edsl/questions/response_validator_factory.py +34 -0
  124. edsl/questions/templates/matrix/__init__.py +1 -0
  125. edsl/questions/templates/matrix/answering_instructions.jinja +5 -0
  126. edsl/questions/templates/matrix/question_presentation.jinja +20 -0
  127. edsl/results/CSSParameterizer.py +1 -1
  128. edsl/results/Dataset.py +170 -7
  129. edsl/results/DatasetExportMixin.py +168 -305
  130. edsl/results/DatasetTree.py +28 -8
  131. edsl/results/MarkdownToDocx.py +122 -0
  132. edsl/results/MarkdownToPDF.py +111 -0
  133. edsl/results/Result.py +298 -206
  134. edsl/results/Results.py +149 -131
  135. edsl/results/ResultsExportMixin.py +2 -0
  136. edsl/results/TableDisplay.py +98 -171
  137. edsl/results/TextEditor.py +50 -0
  138. edsl/results/__init__.py +1 -1
  139. edsl/results/file_exports.py +252 -0
  140. edsl/results/{Selector.py → results_selector.py} +23 -13
  141. edsl/results/smart_objects.py +96 -0
  142. edsl/results/table_data_class.py +12 -0
  143. edsl/results/table_renderers.py +118 -0
  144. edsl/scenarios/ConstructDownloadLink.py +109 -0
  145. edsl/scenarios/DocumentChunker.py +102 -0
  146. edsl/scenarios/DocxScenario.py +16 -0
  147. edsl/scenarios/FileStore.py +150 -239
  148. edsl/scenarios/PdfExtractor.py +40 -0
  149. edsl/scenarios/Scenario.py +90 -193
  150. edsl/scenarios/ScenarioHtmlMixin.py +4 -3
  151. edsl/scenarios/ScenarioList.py +415 -244
  152. edsl/scenarios/ScenarioListExportMixin.py +0 -7
  153. edsl/scenarios/ScenarioListPdfMixin.py +15 -37
  154. edsl/scenarios/__init__.py +1 -2
  155. edsl/scenarios/directory_scanner.py +96 -0
  156. edsl/scenarios/file_methods.py +85 -0
  157. edsl/scenarios/handlers/__init__.py +13 -0
  158. edsl/scenarios/handlers/csv.py +49 -0
  159. edsl/scenarios/handlers/docx.py +76 -0
  160. edsl/scenarios/handlers/html.py +37 -0
  161. edsl/scenarios/handlers/json.py +111 -0
  162. edsl/scenarios/handlers/latex.py +5 -0
  163. edsl/scenarios/handlers/md.py +51 -0
  164. edsl/scenarios/handlers/pdf.py +68 -0
  165. edsl/scenarios/handlers/png.py +39 -0
  166. edsl/scenarios/handlers/pptx.py +105 -0
  167. edsl/scenarios/handlers/py.py +294 -0
  168. edsl/scenarios/handlers/sql.py +313 -0
  169. edsl/scenarios/handlers/sqlite.py +149 -0
  170. edsl/scenarios/handlers/txt.py +33 -0
  171. edsl/scenarios/{ScenarioJoin.py → scenario_join.py} +10 -6
  172. edsl/scenarios/scenario_selector.py +156 -0
  173. edsl/study/ObjectEntry.py +1 -1
  174. edsl/study/SnapShot.py +1 -1
  175. edsl/study/Study.py +5 -12
  176. edsl/surveys/ConstructDAG.py +92 -0
  177. edsl/surveys/EditSurvey.py +221 -0
  178. edsl/surveys/InstructionHandler.py +100 -0
  179. edsl/surveys/MemoryManagement.py +72 -0
  180. edsl/surveys/Rule.py +5 -4
  181. edsl/surveys/RuleCollection.py +25 -27
  182. edsl/surveys/RuleManager.py +172 -0
  183. edsl/surveys/Simulator.py +75 -0
  184. edsl/surveys/Survey.py +270 -791
  185. edsl/surveys/SurveyCSS.py +20 -8
  186. edsl/surveys/{SurveyFlowVisualizationMixin.py → SurveyFlowVisualization.py} +11 -9
  187. edsl/surveys/SurveyToApp.py +141 -0
  188. edsl/surveys/__init__.py +4 -2
  189. edsl/surveys/descriptors.py +6 -2
  190. edsl/surveys/instructions/ChangeInstruction.py +1 -2
  191. edsl/surveys/instructions/Instruction.py +4 -13
  192. edsl/surveys/instructions/InstructionCollection.py +11 -6
  193. edsl/templates/error_reporting/interview_details.html +1 -1
  194. edsl/templates/error_reporting/report.html +1 -1
  195. edsl/tools/plotting.py +1 -1
  196. edsl/utilities/PrettyList.py +56 -0
  197. edsl/utilities/is_notebook.py +18 -0
  198. edsl/utilities/is_valid_variable_name.py +11 -0
  199. edsl/utilities/remove_edsl_version.py +24 -0
  200. edsl/utilities/utilities.py +35 -23
  201. {edsl-0.1.38.dev4.dist-info → edsl-0.1.39.dist-info}/METADATA +12 -10
  202. edsl-0.1.39.dist-info/RECORD +358 -0
  203. {edsl-0.1.38.dev4.dist-info → edsl-0.1.39.dist-info}/WHEEL +1 -1
  204. edsl/language_models/KeyLookup.py +0 -30
  205. edsl/language_models/registry.py +0 -190
  206. edsl/language_models/unused/ReplicateBase.py +0 -83
  207. edsl/results/ResultsDBMixin.py +0 -238
  208. edsl-0.1.38.dev4.dist-info/RECORD +0 -277
  209. /edsl/questions/{RegisterQuestionsMeta.py → register_questions_meta.py} +0 -0
  210. /edsl/results/{ResultsFetchMixin.py → results_fetch_mixin.py} +0 -0
  211. /edsl/results/{ResultsToolsMixin.py → results_tools_mixin.py} +0 -0
  212. {edsl-0.1.38.dev4.dist-info → edsl-0.1.39.dist-info}/LICENSE +0 -0
@@ -1,47 +1,78 @@
1
- from typing import Optional, Union, Literal
2
- import requests
3
- import sys
1
+ from typing import Optional, Union, Literal, TYPE_CHECKING, NewType, Callable, Any
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ Seconds = NewType("Seconds", float)
7
+ JobUUID = NewType("JobUUID", str)
8
+
4
9
  from edsl.exceptions.coop import CoopServerResponseError
5
10
 
6
- # from edsl.enums import VisibilityType
7
- from edsl.results import Results
11
+ if TYPE_CHECKING:
12
+ from edsl.results.Results import Results
13
+ from edsl.jobs.Jobs import Jobs
14
+ from edsl.coop.coop import RemoteInferenceResponse, RemoteInferenceCreationInfo
15
+ from edsl.jobs.JobsRemoteInferenceLogger import JobLogger
16
+
17
+ from edsl.coop.coop import RemoteInferenceResponse, RemoteInferenceCreationInfo
18
+
19
+ from edsl.jobs.jobs_status_enums import JobsStatus
20
+ from edsl.coop.utils import VisibilityType
21
+ from edsl.jobs.JobsRemoteInferenceLogger import JobLogger
22
+
23
+
24
+ class RemoteJobConstants:
25
+ """Constants for remote job handling."""
26
+
27
+ REMOTE_JOB_POLL_INTERVAL = 1
28
+ REMOTE_JOB_VERBOSE = False
29
+ DISCORD_URL = "https://discord.com/invite/mxAYkjfy9m"
30
+
31
+
32
+ @dataclass
33
+ class RemoteJobInfo:
34
+ creation_data: RemoteInferenceCreationInfo
35
+ job_uuid: JobUUID
36
+ logger: JobLogger
8
37
 
9
38
 
10
39
  class JobsRemoteInferenceHandler:
11
- def __init__(self, jobs, verbose=False, poll_interval=3):
12
- """
13
- >>> from edsl.jobs import Jobs
14
- >>> jh = JobsRemoteInferenceHandler(Jobs.example(), verbose=True)
15
- >>> jh.use_remote_inference(True)
16
- False
17
- >>> jh._poll_remote_inference_job({'uuid':1234}, testing_simulated_response={"status": "failed"}) # doctest: +NORMALIZE_WHITESPACE
18
- Job failed.
19
- ...
20
- >>> jh._poll_remote_inference_job({'uuid':1234}, testing_simulated_response={"status": "completed"}) # doctest: +NORMALIZE_WHITESPACE
21
- Job completed and Results stored on Coop: None.
22
- Results(...)
23
- """
40
+ def __init__(
41
+ self,
42
+ jobs: "Jobs",
43
+ verbose: bool = RemoteJobConstants.REMOTE_JOB_VERBOSE,
44
+ poll_interval: Seconds = RemoteJobConstants.REMOTE_JOB_POLL_INTERVAL,
45
+ ):
46
+ """Handles the creation and running of a remote inference job."""
24
47
  self.jobs = jobs
25
48
  self.verbose = verbose
26
49
  self.poll_interval = poll_interval
27
50
 
28
- self._remote_job_creation_data = None
29
- self._job_uuid = None
51
+ from edsl.config import CONFIG
52
+
53
+ self.expected_parrot_url = CONFIG.get("EXPECTED_PARROT_URL")
54
+ self.remote_inference_url = f"{self.expected_parrot_url}/home/remote-inference"
30
55
 
31
- @property
32
- def remote_job_creation_data(self):
33
- return self._remote_job_creation_data
56
+ def _create_logger(self) -> JobLogger:
57
+ from edsl.utilities.is_notebook import is_notebook
58
+ from edsl.jobs.JobsRemoteInferenceLogger import (
59
+ JupyterJobLogger,
60
+ StdOutJobLogger,
61
+ )
62
+ from edsl.jobs.loggers.HTMLTableJobLogger import HTMLTableJobLogger
34
63
 
35
- @property
36
- def job_uuid(self):
37
- return self._job_uuid
64
+ if is_notebook():
65
+ return HTMLTableJobLogger(verbose=self.verbose)
66
+ return StdOutJobLogger(verbose=self.verbose)
38
67
 
39
68
  def use_remote_inference(self, disable_remote_inference: bool) -> bool:
69
+ import requests
70
+
40
71
  if disable_remote_inference:
41
72
  return False
42
73
  if not disable_remote_inference:
43
74
  try:
44
- from edsl import Coop
75
+ from edsl.coop.coop import Coop
45
76
 
46
77
  user_edsl_settings = Coop().edsl_settings
47
78
  return user_edsl_settings.get("remote_inference", False)
@@ -56,16 +87,19 @@ class JobsRemoteInferenceHandler:
56
87
  self,
57
88
  iterations: int = 1,
58
89
  remote_inference_description: Optional[str] = None,
59
- remote_inference_results_visibility: Optional["VisibilityType"] = "unlisted",
60
- verbose=False,
61
- ):
62
- """ """
90
+ remote_inference_results_visibility: Optional[VisibilityType] = "unlisted",
91
+ ) -> RemoteJobInfo:
92
+
63
93
  from edsl.config import CONFIG
64
94
  from edsl.coop.coop import Coop
65
- from rich import print as rich_print
95
+
96
+ logger = self._create_logger()
66
97
 
67
98
  coop = Coop()
68
- print("Remote inference activated. Sending job to server...")
99
+ logger.update(
100
+ "Remote inference activated. Sending job to server...",
101
+ status=JobsStatus.QUEUED,
102
+ )
69
103
  remote_job_creation_data = coop.remote_inference_create(
70
104
  self.jobs,
71
105
  description=remote_inference_description,
@@ -73,136 +107,172 @@ class JobsRemoteInferenceHandler:
73
107
  iterations=iterations,
74
108
  initial_results_visibility=remote_inference_results_visibility,
75
109
  )
110
+ logger.update(
111
+ "Your survey is running at the Expected Parrot server...",
112
+ status=JobsStatus.RUNNING,
113
+ )
76
114
  job_uuid = remote_job_creation_data.get("uuid")
77
- print(f"Job sent to server. (Job uuid={job_uuid}).")
78
-
79
- expected_parrot_url = CONFIG.get("EXPECTED_PARROT_URL")
80
- progress_bar_url = f"{expected_parrot_url}/home/remote-job-progress/{job_uuid}"
115
+ logger.update(
116
+ message=f"Job sent to server. (Job uuid={job_uuid}).",
117
+ status=JobsStatus.RUNNING,
118
+ )
119
+ logger.add_info("job_uuid", job_uuid)
81
120
 
82
- rich_print(
83
- f"View job progress here: [#38bdf8][link={progress_bar_url}]{progress_bar_url}[/link][/#38bdf8]"
121
+ logger.update(
122
+ f"Job details are available at your Coop account {self.remote_inference_url}",
123
+ status=JobsStatus.RUNNING,
124
+ )
125
+ progress_bar_url = (
126
+ f"{self.expected_parrot_url}/home/remote-job-progress/{job_uuid}"
127
+ )
128
+ logger.add_info("progress_bar_url", progress_bar_url)
129
+ logger.update(
130
+ f"View job progress here: {progress_bar_url}", status=JobsStatus.RUNNING
84
131
  )
85
132
 
86
- self._remote_job_creation_data = remote_job_creation_data
87
- self._job_uuid = job_uuid
88
- # return remote_job_creation_data
133
+ return RemoteJobInfo(
134
+ creation_data=remote_job_creation_data,
135
+ job_uuid=job_uuid,
136
+ logger=logger,
137
+ )
89
138
 
90
139
  @staticmethod
91
- def check_status(job_uuid):
140
+ def check_status(
141
+ job_uuid: JobUUID,
142
+ ) -> RemoteInferenceResponse:
92
143
  from edsl.coop.coop import Coop
93
144
 
94
145
  coop = Coop()
95
146
  return coop.remote_inference_get(job_uuid)
96
147
 
97
- def poll_remote_inference_job(self):
98
- return self._poll_remote_inference_job(
99
- self.remote_job_creation_data, verbose=self.verbose
148
+ def _construct_remote_job_fetcher(
149
+ self, testing_simulated_response: Optional[Any] = None
150
+ ) -> Callable:
151
+ if testing_simulated_response is not None:
152
+ return lambda job_uuid: testing_simulated_response
153
+ else:
154
+ from edsl.coop.coop import Coop
155
+
156
+ coop = Coop()
157
+ return coop.remote_inference_get
158
+
159
+ def _construct_object_fetcher(
160
+ self, testing_simulated_response: Optional[Any] = None
161
+ ) -> Callable:
162
+ "Constructs a function to fetch the results object from Coop."
163
+ if testing_simulated_response is not None:
164
+ return lambda results_uuid, expected_object_type: Results.example()
165
+ else:
166
+ from edsl.coop.coop import Coop
167
+
168
+ coop = Coop()
169
+ return coop.get
170
+
171
+ def _handle_cancelled_job(self, job_info: RemoteJobInfo) -> None:
172
+ "Handles a cancelled job by logging the cancellation and updating the job status."
173
+
174
+ job_info.logger.update(
175
+ message="Job cancelled by the user.", status=JobsStatus.CANCELLED
176
+ )
177
+ job_info.logger.update(
178
+ f"See {self.expected_parrot_url}/home/remote-inference for more details.",
179
+ status=JobsStatus.CANCELLED,
100
180
  )
101
181
 
102
- def _poll_remote_inference_job(
103
- self,
104
- remote_job_creation_data: dict,
105
- verbose=False,
106
- poll_interval: Optional[float] = None,
107
- testing_simulated_response: Optional[dict] = None,
108
- ) -> Union[Results, None]:
182
+ def _handle_failed_job(
183
+ self, job_info: RemoteJobInfo, remote_job_data: RemoteInferenceResponse
184
+ ) -> None:
185
+ "Handles a failed job by logging the error and updating the job status."
186
+ latest_error_report_url = remote_job_data.get("latest_error_report_url")
187
+ if latest_error_report_url:
188
+ job_info.logger.add_info("error_report_url", latest_error_report_url)
189
+
190
+ job_info.logger.update("Job failed.", status=JobsStatus.FAILED)
191
+ job_info.logger.update(
192
+ f"See {self.expected_parrot_url}/home/remote-inference for more details.",
193
+ status=JobsStatus.FAILED,
194
+ )
195
+ job_info.logger.update(
196
+ f"Need support? Visit Discord: {RemoteJobConstants.DISCORD_URL}",
197
+ status=JobsStatus.FAILED,
198
+ )
199
+
200
+ def _sleep_for_a_bit(self, job_info: RemoteJobInfo, status: str) -> None:
109
201
  import time
110
202
  from datetime import datetime
111
- from edsl.config import CONFIG
112
- from edsl.coop.coop import Coop
113
203
 
114
- if poll_interval is None:
115
- poll_interval = self.poll_interval
204
+ time_checked = datetime.now().strftime("%Y-%m-%d %I:%M:%S %p")
205
+ job_info.logger.update(
206
+ f"Job status: {status} - last update: {time_checked}",
207
+ status=JobsStatus.RUNNING,
208
+ )
209
+ time.sleep(self.poll_interval)
116
210
 
117
- expected_parrot_url = CONFIG.get("EXPECTED_PARROT_URL")
211
+ def _fetch_results_and_log(
212
+ self,
213
+ job_info: RemoteJobInfo,
214
+ results_uuid: str,
215
+ remote_job_data: RemoteInferenceResponse,
216
+ object_fetcher: Callable,
217
+ ) -> "Results":
218
+ "Fetches the results object and logs the results URL."
219
+ job_info.logger.add_info("results_uuid", results_uuid)
220
+ results = object_fetcher(results_uuid, expected_object_type="results")
221
+ results_url = remote_job_data.get("results_url")
222
+ job_info.logger.update(
223
+ f"Job completed and Results stored on Coop: {results_url}",
224
+ status=JobsStatus.COMPLETED,
225
+ )
226
+ results.job_uuid = job_info.job_uuid
227
+ results.results_uuid = results_uuid
228
+ return results
118
229
 
119
- job_uuid = remote_job_creation_data.get("uuid")
120
- coop = Coop()
230
+ def poll_remote_inference_job(
231
+ self,
232
+ job_info: RemoteJobInfo,
233
+ testing_simulated_response=None,
234
+ ) -> Union[None, "Results"]:
235
+ """Polls a remote inference job for completion and returns the results."""
121
236
 
122
- if testing_simulated_response is not None:
123
- remote_job_data_fetcher = lambda job_uuid: testing_simulated_response
124
- object_fetcher = (
125
- lambda results_uuid, expected_object_type: Results.example()
126
- )
127
- else:
128
- remote_job_data_fetcher = coop.remote_inference_get
129
- object_fetcher = coop.get
237
+ remote_job_data_fetcher = self._construct_remote_job_fetcher(
238
+ testing_simulated_response
239
+ )
240
+ object_fetcher = self._construct_object_fetcher(testing_simulated_response)
130
241
 
131
242
  job_in_queue = True
132
243
  while job_in_queue:
133
- remote_job_data = remote_job_data_fetcher(job_uuid)
244
+ remote_job_data = remote_job_data_fetcher(job_info.job_uuid)
134
245
  status = remote_job_data.get("status")
246
+
135
247
  if status == "cancelled":
136
- print("\r" + " " * 80 + "\r", end="")
137
- print("Job cancelled by the user.")
138
- print(
139
- f"See {expected_parrot_url}/home/remote-inference for more details."
140
- )
141
- return None
142
- elif status == "failed":
143
- print("\r" + " " * 80 + "\r", end="")
144
- # write to stderr
145
- latest_error_report_url = remote_job_data.get("latest_error_report_url")
146
- if latest_error_report_url:
147
- print("Job failed.")
148
- print(
149
- f"Your job generated exceptions. Details on these exceptions can be found in the following report: {latest_error_report_url}"
150
- )
151
- print(
152
- f"Need support? Post a message at the Expected Parrot Discord channel (https://discord.com/invite/mxAYkjfy9m) or send an email to info@expectedparrot.com."
153
- )
154
- else:
155
- print("Job failed.")
156
- print(
157
- f"See {expected_parrot_url}/home/remote-inference for more details."
158
- )
248
+ self._handle_cancelled_job(job_info)
159
249
  return None
160
- elif status == "completed":
161
- results_uuid = remote_job_data.get("results_uuid")
162
- results_url = remote_job_data.get("results_url")
163
- results = object_fetcher(results_uuid, expected_object_type="results")
164
- print("\r" + " " * 80 + "\r", end="")
165
- print(f"Job completed and Results stored on Coop: {results_url}.")
166
- return results
167
- else:
168
- duration = poll_interval
169
- time_checked = datetime.now().strftime("%Y-%m-%d %I:%M:%S %p")
170
- frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
171
- start_time = time.time()
172
- i = 0
173
- while time.time() - start_time < duration:
174
- print(
175
- f"\r{frames[i % len(frames)]} Job status: {status} - last update: {time_checked}",
176
- end="",
177
- flush=True,
178
- )
179
- time.sleep(0.1)
180
- i += 1
181
250
 
182
- def use_remote_inference(self, disable_remote_inference: bool) -> bool:
183
- if disable_remote_inference:
184
- return False
185
- if not disable_remote_inference:
186
- try:
187
- from edsl import Coop
251
+ elif status == "failed" or status == "completed":
252
+ if status == "failed":
253
+ self._handle_failed_job(job_info, remote_job_data)
188
254
 
189
- user_edsl_settings = Coop().edsl_settings
190
- return user_edsl_settings.get("remote_inference", False)
191
- except requests.ConnectionError:
192
- pass
193
- except CoopServerResponseError as e:
194
- pass
255
+ results_uuid = remote_job_data.get("results_uuid")
256
+ if results_uuid:
257
+ results = self._fetch_results_and_log(
258
+ job_info=job_info,
259
+ results_uuid=results_uuid,
260
+ remote_job_data=remote_job_data,
261
+ object_fetcher=object_fetcher,
262
+ )
263
+ return results
264
+ else:
265
+ return None
195
266
 
196
- return False
267
+ else:
268
+ self._sleep_for_a_bit(job_info, status)
197
269
 
198
270
  async def create_and_poll_remote_job(
199
271
  self,
200
272
  iterations: int = 1,
201
273
  remote_inference_description: Optional[str] = None,
202
- remote_inference_results_visibility: Optional[
203
- Literal["private", "public", "unlisted"]
204
- ] = "unlisted",
205
- ) -> Union[Results, None]:
274
+ remote_inference_results_visibility: Optional[VisibilityType] = "unlisted",
275
+ ) -> Union["Results", None]:
206
276
  """
207
277
  Creates and polls a remote inference job asynchronously.
208
278
  Reuses existing synchronous methods but runs them in an async context.
@@ -217,7 +287,7 @@ class JobsRemoteInferenceHandler:
217
287
 
218
288
  # Create job using existing method
219
289
  loop = asyncio.get_event_loop()
220
- remote_job_creation_data = await loop.run_in_executor(
290
+ job_info = await loop.run_in_executor(
221
291
  None,
222
292
  partial(
223
293
  self.create_remote_inference_job,
@@ -226,10 +296,12 @@ class JobsRemoteInferenceHandler:
226
296
  remote_inference_results_visibility=remote_inference_results_visibility,
227
297
  ),
228
298
  )
299
+ if job_info is None:
300
+ raise ValueError("Remote job creation failed.")
229
301
 
230
- # Poll using existing method but with async sleep
231
302
  return await loop.run_in_executor(
232
- None, partial(self.poll_remote_inference_job, remote_job_creation_data)
303
+ None,
304
+ partial(self.poll_remote_inference_job, job_info),
233
305
  )
234
306
 
235
307
 
@@ -0,0 +1,239 @@
1
+ import re
2
+ import sys
3
+ import uuid
4
+ from abc import ABC, abstractmethod
5
+ from typing import Optional, Union, Literal, TYPE_CHECKING, List, Dict
6
+ from datetime import datetime
7
+ from dataclasses import dataclass
8
+ from edsl.exceptions.coop import CoopServerResponseError
9
+
10
+ from edsl.jobs.jobs_status_enums import JobsStatus
11
+
12
+ if TYPE_CHECKING:
13
+ from edsl.results.Results import Results
14
+
15
+
16
+ @dataclass
17
+ class LogMessage:
18
+ text: str
19
+ status: str
20
+ timestamp: datetime
21
+ status: JobsStatus
22
+
23
+
24
+ @dataclass
25
+ class JobsInfo:
26
+ job_uuid: str = None
27
+ progress_bar_url: str = None
28
+ error_report_url: str = None
29
+ results_uuid: str = None
30
+ results_url: str = None
31
+
32
+ pretty_names = {
33
+ "job_uuid": "Job UUID",
34
+ "progress_bar_url": "Progress Bar URL",
35
+ "error_report_url": "Error Report URL",
36
+ "results_uuid": "Results UUID",
37
+ "results_url": "Results URL",
38
+ }
39
+
40
+
41
+ class JobLogger(ABC):
42
+ def __init__(self, verbose: bool = False):
43
+ self.verbose = verbose
44
+ self.jobs_info = JobsInfo()
45
+
46
+ def add_info(
47
+ self,
48
+ information_type: Literal[
49
+ "job_uuid",
50
+ "progress_bar_url",
51
+ "error_report_url",
52
+ "results_uuid",
53
+ "results_url",
54
+ ],
55
+ value: str,
56
+ ):
57
+ """Add information to the logger
58
+
59
+ >>> j = StdOutJobLogger()
60
+ >>> j.add_info("job_uuid", "1234")
61
+ >>> j.jobs_info.job_uuid
62
+ '1234'
63
+ """
64
+ if information_type not in self.jobs_info.__annotations__:
65
+ raise ValueError(f"Information type {information_type} not supported")
66
+ setattr(self.jobs_info, information_type, value)
67
+
68
+ @abstractmethod
69
+ def update(self, message: str, status: str = "running"):
70
+ pass
71
+
72
+
73
+ class HTMLTableJobLogger(JobLogger):
74
+ def __init__(self, verbose=True, **kwargs):
75
+ from IPython.display import display, HTML
76
+
77
+ super().__init__(verbose=verbose)
78
+ self.display_handle = display(HTML(""), display_id=True)
79
+ self.current_message = None
80
+ self.log_id = str(uuid.uuid4())
81
+ self.is_expanded = True
82
+ self.spinner_chars = ["◐", "◓", "◑", "◒"] # Rotating spinner characters
83
+ self.spinner_idx = 0
84
+
85
+ def _get_table_row(self, key: str, value: str) -> str:
86
+ """Generate a table row with key-value pair"""
87
+ return f"""
88
+ <tr>
89
+ <td style="padding: 8px; border: 1px solid #ddd; font-weight: bold;">{key}</td>
90
+ <td style="padding: 8px; border: 1px solid #ddd;">{value if value else 'None'}</td>
91
+ </tr>
92
+ """
93
+
94
+ def _linkify(self, text: str) -> str:
95
+ """Convert URLs in text to clickable links"""
96
+ url_pattern = r'(https?://[^\s<>"]+|www\.[^\s<>"]+)'
97
+ return re.sub(
98
+ url_pattern,
99
+ r'<a href="\1" target="_blank" style="color: #3b82f6; text-decoration: underline;">\1</a>',
100
+ text,
101
+ )
102
+
103
+ def _get_spinner(self, status: JobsStatus) -> str:
104
+ """Get the current spinner frame if status is running"""
105
+ if status == JobsStatus.RUNNING:
106
+ spinner = self.spinner_chars[self.spinner_idx]
107
+ self.spinner_idx = (self.spinner_idx + 1) % len(self.spinner_chars)
108
+ return f'<span style="margin-right: 8px;">{spinner}</span>'
109
+ elif status == JobsStatus.COMPLETED:
110
+ return '<span style="margin-right: 8px; color: #22c55e;">✓</span>'
111
+ elif status == JobsStatus.FAILED:
112
+ return '<span style="margin-right: 8px; color: #ef4444;">✗</span>'
113
+ return ""
114
+
115
+ def _get_html(self, status: JobsStatus = JobsStatus.RUNNING) -> str:
116
+ """Generate the complete HTML display"""
117
+ # Generate table rows for each JobsInfo field
118
+ info_rows = ""
119
+ for field, _ in self.jobs_info.__annotations__.items():
120
+ if field != "pretty_names": # Skip the pretty_names dictionary
121
+ value = getattr(self.jobs_info, field)
122
+ value = self._linkify(str(value)) if value else None
123
+ pretty_name = self.jobs_info.pretty_names.get(
124
+ field, field.replace("_", " ").title()
125
+ )
126
+ info_rows += self._get_table_row(pretty_name, value)
127
+
128
+ # Add current message section with spinner
129
+ message_html = ""
130
+ if self.current_message:
131
+ spinner = self._get_spinner(status)
132
+ message_html = f"""
133
+ <div style="margin-top: 10px; padding: 8px; background-color: #f8f9fa; border: 1px solid #ddd; border-radius: 4px;">
134
+ {spinner}<strong>Current Status:</strong> {self._linkify(self.current_message)}
135
+ </div>
136
+ """
137
+
138
+ display_style = "block" if self.is_expanded else "none"
139
+ arrow = "▼" if self.is_expanded else "▶"
140
+
141
+ return f"""
142
+ <div style="font-family: system-ui; max-width: 800px; margin: 10px 0;">
143
+ <div onclick="document.getElementById('content-{self.log_id}').style.display = document.getElementById('content-{self.log_id}').style.display === 'none' ? 'block' : 'none';
144
+ document.getElementById('arrow-{self.log_id}').innerHTML = document.getElementById('content-{self.log_id}').style.display === 'none' ? '▶' : '▼';"
145
+ style="padding: 10px; background: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">
146
+ <span id="arrow-{self.log_id}">{arrow}</span> Job Status ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})
147
+ </div>
148
+ <div id="content-{self.log_id}" style="display: {display_style};">
149
+ <table style="width: 100%; border-collapse: collapse; background: white; border: 1px solid #ddd;">
150
+ {info_rows}
151
+ </table>
152
+ {message_html}
153
+ </div>
154
+ </div>
155
+ """
156
+
157
+ def update(self, message: str, status: JobsStatus = JobsStatus.RUNNING):
158
+ """Update the display with new message and current JobsInfo state"""
159
+ from IPython.display import HTML
160
+
161
+ self.current_message = message
162
+ if self.verbose:
163
+ self.display_handle.update(HTML(self._get_html(status)))
164
+ else:
165
+ return None
166
+
167
+
168
+ class StdOutJobLogger(JobLogger):
169
+ def __init__(self, verbose=True, **kwargs):
170
+ super().__init__(verbose=verbose) # Properly call parent's __init__
171
+ self.messages: List[LogMessage] = []
172
+
173
+ def update(self, message: str, status: JobsStatus = JobsStatus.RUNNING):
174
+ log_msg = LogMessage(text=message, status=status, timestamp=datetime.now())
175
+ self.messages.append(log_msg)
176
+ if self.verbose:
177
+ sys.stdout.write(f"│ {message}\n")
178
+ sys.stdout.flush()
179
+ else:
180
+ return None
181
+
182
+
183
+ class JupyterJobLogger(JobLogger):
184
+ def __init__(self, verbose=True, **kwargs):
185
+ from IPython.display import display, HTML
186
+
187
+ super().__init__(verbose=verbose)
188
+ self.messages = []
189
+ self.log_id = str(uuid.uuid4())
190
+ self.is_expanded = True
191
+ self.display_handle = display(HTML(""), display_id=True)
192
+
193
+ def _linkify(self, text):
194
+ url_pattern = r'(https?://[^\s<>"]+|www\.[^\s<>"]+)'
195
+ return re.sub(
196
+ url_pattern,
197
+ r'<a href="\1" target="_blank" style="color: #3b82f6; text-decoration: underline;">\1</a>',
198
+ text,
199
+ )
200
+
201
+ def _get_html(self):
202
+ messages_html = "\n".join(
203
+ [
204
+ f'<div style="border-left: 3px solid {msg["color"]}; padding: 5px 10px; margin: 5px 0;">{self._linkify(msg["text"])}</div>'
205
+ for msg in self.messages
206
+ ]
207
+ )
208
+
209
+ display_style = "block" if self.is_expanded else "none"
210
+ arrow = "▼" if self.is_expanded else "▶"
211
+
212
+ return f"""
213
+ <div style="border: 1px solid #ccc; margin: 10px 0; max-width: 800px;">
214
+ <div onclick="document.getElementById('content-{self.log_id}').style.display = document.getElementById('content-{self.log_id}').style.display === 'none' ? 'block' : 'none';
215
+ document.getElementById('arrow-{self.log_id}').innerHTML = document.getElementById('content-{self.log_id}').style.display === 'none' ? '▶' : '▼';"
216
+ style="padding: 10px; background: #f5f5f5; cursor: pointer;">
217
+ <span id="arrow-{self.log_id}">{arrow}</span> Remote Job Log ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})
218
+ </div>
219
+ <div id="content-{self.log_id}" style="padding: 10px; display: {display_style};">
220
+ {messages_html}
221
+ </div>
222
+ </div>
223
+ """
224
+
225
+ def update(self, message, status: JobsStatus = JobsStatus.RUNNING):
226
+ from IPython.display import HTML
227
+
228
+ colors = {"running": "#3b82f6", "completed": "#22c55e", "failed": "#ef4444"}
229
+ self.messages.append({"text": message, "color": colors.get(status, "#666")})
230
+ if self.verbose:
231
+ self.display_handle.update(HTML(self._get_html()))
232
+ else:
233
+ return None
234
+
235
+
236
+ if __name__ == "__main__":
237
+ import doctest
238
+
239
+ doctest.testmod()