edsl 0.1.46__py3-none-any.whl → 0.1.48__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 (328) hide show
  1. edsl/__init__.py +44 -39
  2. edsl/__version__.py +1 -1
  3. edsl/agents/__init__.py +4 -2
  4. edsl/agents/{Agent.py → agent.py} +442 -152
  5. edsl/agents/{AgentList.py → agent_list.py} +220 -162
  6. edsl/agents/descriptors.py +46 -7
  7. edsl/{exceptions/agents.py → agents/exceptions.py} +3 -12
  8. edsl/base/__init__.py +75 -0
  9. edsl/base/base_class.py +1303 -0
  10. edsl/base/data_transfer_models.py +114 -0
  11. edsl/base/enums.py +215 -0
  12. edsl/base.py +8 -0
  13. edsl/buckets/__init__.py +25 -0
  14. edsl/buckets/bucket_collection.py +324 -0
  15. edsl/buckets/model_buckets.py +206 -0
  16. edsl/buckets/token_bucket.py +502 -0
  17. edsl/{jobs/buckets/TokenBucketAPI.py → buckets/token_bucket_api.py} +1 -1
  18. edsl/buckets/token_bucket_client.py +509 -0
  19. edsl/caching/__init__.py +20 -0
  20. edsl/caching/cache.py +814 -0
  21. edsl/caching/cache_entry.py +427 -0
  22. edsl/{data/CacheHandler.py → caching/cache_handler.py} +14 -15
  23. edsl/caching/exceptions.py +24 -0
  24. edsl/caching/orm.py +30 -0
  25. edsl/{data/RemoteCacheSync.py → caching/remote_cache_sync.py} +3 -3
  26. edsl/caching/sql_dict.py +441 -0
  27. edsl/config/__init__.py +8 -0
  28. edsl/config/config_class.py +177 -0
  29. edsl/config.py +4 -176
  30. edsl/conversation/Conversation.py +7 -7
  31. edsl/conversation/car_buying.py +4 -4
  32. edsl/conversation/chips.py +6 -6
  33. edsl/coop/__init__.py +25 -2
  34. edsl/coop/coop.py +430 -113
  35. edsl/coop/{ExpectedParrotKeyHandler.py → ep_key_handling.py} +86 -10
  36. edsl/coop/exceptions.py +62 -0
  37. edsl/coop/price_fetcher.py +126 -0
  38. edsl/coop/utils.py +89 -24
  39. edsl/data_transfer_models.py +5 -72
  40. edsl/dataset/__init__.py +10 -0
  41. edsl/{results/Dataset.py → dataset/dataset.py} +116 -36
  42. edsl/dataset/dataset_operations_mixin.py +1492 -0
  43. edsl/{results/DatasetTree.py → dataset/dataset_tree.py} +156 -75
  44. edsl/{results/TableDisplay.py → dataset/display/table_display.py} +18 -7
  45. edsl/{results → dataset/display}/table_renderers.py +58 -2
  46. edsl/{results → dataset}/file_exports.py +4 -5
  47. edsl/{results → dataset}/smart_objects.py +2 -2
  48. edsl/enums.py +5 -205
  49. edsl/inference_services/__init__.py +5 -0
  50. edsl/inference_services/{AvailableModelCacheHandler.py → available_model_cache_handler.py} +2 -3
  51. edsl/inference_services/{AvailableModelFetcher.py → available_model_fetcher.py} +8 -14
  52. edsl/inference_services/data_structures.py +3 -2
  53. edsl/{exceptions/inference_services.py → inference_services/exceptions.py} +1 -1
  54. edsl/inference_services/{InferenceServiceABC.py → inference_service_abc.py} +1 -1
  55. edsl/inference_services/{InferenceServicesCollection.py → inference_services_collection.py} +8 -7
  56. edsl/inference_services/registry.py +4 -41
  57. edsl/inference_services/{ServiceAvailability.py → service_availability.py} +5 -25
  58. edsl/inference_services/services/__init__.py +31 -0
  59. edsl/inference_services/{AnthropicService.py → services/anthropic_service.py} +3 -3
  60. edsl/inference_services/{AwsBedrock.py → services/aws_bedrock.py} +2 -2
  61. edsl/inference_services/{AzureAI.py → services/azure_ai.py} +2 -2
  62. edsl/inference_services/{DeepInfraService.py → services/deep_infra_service.py} +1 -3
  63. edsl/inference_services/{DeepSeekService.py → services/deep_seek_service.py} +2 -4
  64. edsl/inference_services/{GoogleService.py → services/google_service.py} +5 -4
  65. edsl/inference_services/{GroqService.py → services/groq_service.py} +1 -1
  66. edsl/inference_services/{MistralAIService.py → services/mistral_ai_service.py} +3 -3
  67. edsl/inference_services/{OllamaService.py → services/ollama_service.py} +1 -7
  68. edsl/inference_services/{OpenAIService.py → services/open_ai_service.py} +5 -6
  69. edsl/inference_services/{PerplexityService.py → services/perplexity_service.py} +12 -12
  70. edsl/inference_services/{TestService.py → services/test_service.py} +7 -6
  71. edsl/inference_services/{TogetherAIService.py → services/together_ai_service.py} +2 -6
  72. edsl/inference_services/{XAIService.py → services/xai_service.py} +1 -1
  73. edsl/inference_services/write_available.py +1 -2
  74. edsl/instructions/__init__.py +6 -0
  75. edsl/{surveys/instructions/Instruction.py → instructions/instruction.py} +11 -6
  76. edsl/{surveys/instructions/InstructionCollection.py → instructions/instruction_collection.py} +10 -5
  77. edsl/{surveys/InstructionHandler.py → instructions/instruction_handler.py} +3 -3
  78. edsl/{jobs/interviews → interviews}/ReportErrors.py +2 -2
  79. edsl/interviews/__init__.py +4 -0
  80. edsl/{jobs/AnswerQuestionFunctionConstructor.py → interviews/answering_function.py} +45 -18
  81. edsl/{jobs/interviews/InterviewExceptionEntry.py → interviews/exception_tracking.py} +107 -22
  82. edsl/interviews/interview.py +638 -0
  83. edsl/{jobs/interviews/InterviewStatusDictionary.py → interviews/interview_status_dictionary.py} +21 -12
  84. edsl/{jobs/interviews/InterviewStatusLog.py → interviews/interview_status_log.py} +16 -7
  85. edsl/{jobs/InterviewTaskManager.py → interviews/interview_task_manager.py} +12 -7
  86. edsl/{jobs/RequestTokenEstimator.py → interviews/request_token_estimator.py} +8 -3
  87. edsl/{jobs/interviews/InterviewStatistic.py → interviews/statistics.py} +36 -10
  88. edsl/invigilators/__init__.py +38 -0
  89. edsl/invigilators/invigilator_base.py +477 -0
  90. edsl/{agents/Invigilator.py → invigilators/invigilators.py} +263 -10
  91. edsl/invigilators/prompt_constructor.py +476 -0
  92. edsl/{agents → invigilators}/prompt_helpers.py +2 -1
  93. edsl/{agents/QuestionInstructionPromptBuilder.py → invigilators/question_instructions_prompt_builder.py} +18 -13
  94. edsl/{agents → invigilators}/question_option_processor.py +96 -21
  95. edsl/{agents/QuestionTemplateReplacementsBuilder.py → invigilators/question_template_replacements_builder.py} +64 -12
  96. edsl/jobs/__init__.py +7 -1
  97. edsl/jobs/async_interview_runner.py +99 -35
  98. edsl/jobs/check_survey_scenario_compatibility.py +7 -5
  99. edsl/jobs/data_structures.py +153 -22
  100. edsl/{exceptions/jobs.py → jobs/exceptions.py} +2 -1
  101. edsl/jobs/{FetchInvigilator.py → fetch_invigilator.py} +4 -4
  102. edsl/jobs/{loggers/HTMLTableJobLogger.py → html_table_job_logger.py} +6 -2
  103. edsl/jobs/{Jobs.py → jobs.py} +321 -155
  104. edsl/jobs/{JobsChecks.py → jobs_checks.py} +15 -7
  105. edsl/jobs/{JobsComponentConstructor.py → jobs_component_constructor.py} +20 -17
  106. edsl/jobs/{InterviewsConstructor.py → jobs_interview_constructor.py} +10 -5
  107. edsl/jobs/jobs_pricing_estimation.py +347 -0
  108. edsl/jobs/{JobsRemoteInferenceLogger.py → jobs_remote_inference_logger.py} +4 -3
  109. edsl/jobs/jobs_runner_asyncio.py +282 -0
  110. edsl/jobs/{JobsRemoteInferenceHandler.py → remote_inference.py} +19 -22
  111. edsl/jobs/results_exceptions_handler.py +2 -2
  112. edsl/key_management/__init__.py +28 -0
  113. edsl/key_management/key_lookup.py +161 -0
  114. edsl/{language_models/key_management/KeyLookupBuilder.py → key_management/key_lookup_builder.py} +118 -47
  115. edsl/key_management/key_lookup_collection.py +82 -0
  116. edsl/key_management/models.py +218 -0
  117. edsl/language_models/__init__.py +7 -2
  118. edsl/language_models/{ComputeCost.py → compute_cost.py} +18 -3
  119. edsl/{exceptions/language_models.py → language_models/exceptions.py} +2 -1
  120. edsl/language_models/language_model.py +1080 -0
  121. edsl/language_models/model.py +10 -25
  122. edsl/language_models/{ModelList.py → model_list.py} +9 -14
  123. edsl/language_models/{RawResponseHandler.py → raw_response_handler.py} +1 -1
  124. edsl/language_models/{RegisterLanguageModelsMeta.py → registry.py} +1 -1
  125. edsl/language_models/repair.py +4 -4
  126. edsl/language_models/utilities.py +4 -4
  127. edsl/notebooks/__init__.py +3 -1
  128. edsl/notebooks/{Notebook.py → notebook.py} +7 -8
  129. edsl/prompts/__init__.py +1 -1
  130. edsl/{exceptions/prompts.py → prompts/exceptions.py} +3 -1
  131. edsl/prompts/{Prompt.py → prompt.py} +101 -95
  132. edsl/questions/HTMLQuestion.py +1 -1
  133. edsl/questions/__init__.py +154 -25
  134. edsl/questions/answer_validator_mixin.py +1 -1
  135. edsl/questions/compose_questions.py +4 -3
  136. edsl/questions/derived/question_likert_five.py +166 -0
  137. edsl/questions/derived/{QuestionLinearScale.py → question_linear_scale.py} +4 -4
  138. edsl/questions/derived/{QuestionTopK.py → question_top_k.py} +4 -4
  139. edsl/questions/derived/{QuestionYesNo.py → question_yes_no.py} +4 -5
  140. edsl/questions/descriptors.py +24 -30
  141. edsl/questions/loop_processor.py +65 -19
  142. edsl/questions/question_base.py +881 -0
  143. edsl/questions/question_base_gen_mixin.py +15 -16
  144. edsl/questions/{QuestionBasePromptsMixin.py → question_base_prompts_mixin.py} +2 -2
  145. edsl/questions/{QuestionBudget.py → question_budget.py} +3 -4
  146. edsl/questions/{QuestionCheckBox.py → question_check_box.py} +16 -16
  147. edsl/questions/{QuestionDict.py → question_dict.py} +39 -5
  148. edsl/questions/{QuestionExtract.py → question_extract.py} +9 -9
  149. edsl/questions/question_free_text.py +282 -0
  150. edsl/questions/{QuestionFunctional.py → question_functional.py} +6 -5
  151. edsl/questions/{QuestionList.py → question_list.py} +6 -7
  152. edsl/questions/{QuestionMatrix.py → question_matrix.py} +6 -5
  153. edsl/questions/{QuestionMultipleChoice.py → question_multiple_choice.py} +126 -21
  154. edsl/questions/{QuestionNumerical.py → question_numerical.py} +5 -5
  155. edsl/questions/{QuestionRank.py → question_rank.py} +6 -6
  156. edsl/questions/question_registry.py +10 -16
  157. edsl/questions/register_questions_meta.py +8 -4
  158. edsl/questions/response_validator_abc.py +17 -16
  159. edsl/results/__init__.py +4 -1
  160. edsl/{exceptions/results.py → results/exceptions.py} +1 -1
  161. edsl/results/report.py +197 -0
  162. edsl/results/{Result.py → result.py} +131 -45
  163. edsl/results/{Results.py → results.py} +420 -216
  164. edsl/results/results_selector.py +344 -25
  165. edsl/scenarios/__init__.py +30 -3
  166. edsl/scenarios/{ConstructDownloadLink.py → construct_download_link.py} +7 -0
  167. edsl/scenarios/directory_scanner.py +156 -13
  168. edsl/scenarios/document_chunker.py +186 -0
  169. edsl/scenarios/exceptions.py +101 -0
  170. edsl/scenarios/file_methods.py +2 -3
  171. edsl/scenarios/file_store.py +755 -0
  172. edsl/scenarios/handlers/__init__.py +14 -14
  173. edsl/scenarios/handlers/{csv.py → csv_file_store.py} +1 -2
  174. edsl/scenarios/handlers/{docx.py → docx_file_store.py} +8 -7
  175. edsl/scenarios/handlers/{html.py → html_file_store.py} +1 -2
  176. edsl/scenarios/handlers/{jpeg.py → jpeg_file_store.py} +1 -1
  177. edsl/scenarios/handlers/{json.py → json_file_store.py} +1 -1
  178. edsl/scenarios/handlers/latex_file_store.py +5 -0
  179. edsl/scenarios/handlers/{md.py → md_file_store.py} +1 -1
  180. edsl/scenarios/handlers/{pdf.py → pdf_file_store.py} +2 -2
  181. edsl/scenarios/handlers/{png.py → png_file_store.py} +1 -1
  182. edsl/scenarios/handlers/{pptx.py → pptx_file_store.py} +8 -7
  183. edsl/scenarios/handlers/{py.py → py_file_store.py} +1 -3
  184. edsl/scenarios/handlers/{sql.py → sql_file_store.py} +2 -1
  185. edsl/scenarios/handlers/{sqlite.py → sqlite_file_store.py} +2 -3
  186. edsl/scenarios/handlers/{txt.py → txt_file_store.py} +1 -1
  187. edsl/scenarios/scenario.py +928 -0
  188. edsl/scenarios/scenario_join.py +18 -5
  189. edsl/scenarios/{ScenarioList.py → scenario_list.py} +424 -106
  190. edsl/scenarios/{ScenarioListPdfMixin.py → scenario_list_pdf_tools.py} +16 -15
  191. edsl/scenarios/scenario_selector.py +5 -1
  192. edsl/study/ObjectEntry.py +2 -2
  193. edsl/study/SnapShot.py +5 -5
  194. edsl/study/Study.py +20 -21
  195. edsl/study/__init__.py +6 -4
  196. edsl/surveys/__init__.py +7 -4
  197. edsl/surveys/dag/__init__.py +2 -0
  198. edsl/surveys/{ConstructDAG.py → dag/construct_dag.py} +3 -3
  199. edsl/surveys/{DAG.py → dag/dag.py} +13 -10
  200. edsl/surveys/descriptors.py +1 -1
  201. edsl/surveys/{EditSurvey.py → edit_survey.py} +9 -9
  202. edsl/{exceptions/surveys.py → surveys/exceptions.py} +1 -2
  203. edsl/surveys/memory/__init__.py +3 -0
  204. edsl/surveys/{MemoryPlan.py → memory/memory_plan.py} +10 -9
  205. edsl/surveys/rules/__init__.py +3 -0
  206. edsl/surveys/{Rule.py → rules/rule.py} +103 -43
  207. edsl/surveys/{RuleCollection.py → rules/rule_collection.py} +21 -30
  208. edsl/surveys/{RuleManager.py → rules/rule_manager.py} +19 -13
  209. edsl/surveys/survey.py +1743 -0
  210. edsl/surveys/{SurveyExportMixin.py → survey_export.py} +22 -27
  211. edsl/surveys/{SurveyFlowVisualization.py → survey_flow_visualization.py} +11 -2
  212. edsl/surveys/{Simulator.py → survey_simulator.py} +10 -3
  213. edsl/tasks/__init__.py +32 -0
  214. edsl/{jobs/tasks/QuestionTaskCreator.py → tasks/question_task_creator.py} +115 -57
  215. edsl/tasks/task_creators.py +135 -0
  216. edsl/{jobs/tasks/TaskHistory.py → tasks/task_history.py} +86 -47
  217. edsl/{jobs/tasks → tasks}/task_status_enum.py +91 -7
  218. edsl/tasks/task_status_log.py +85 -0
  219. edsl/tokens/__init__.py +2 -0
  220. edsl/tokens/interview_token_usage.py +53 -0
  221. edsl/utilities/PrettyList.py +1 -1
  222. edsl/utilities/SystemInfo.py +25 -22
  223. edsl/utilities/__init__.py +29 -21
  224. edsl/utilities/gcp_bucket/__init__.py +2 -0
  225. edsl/utilities/gcp_bucket/cloud_storage.py +99 -96
  226. edsl/utilities/interface.py +44 -536
  227. edsl/{results/MarkdownToPDF.py → utilities/markdown_to_pdf.py} +13 -5
  228. edsl/utilities/repair_functions.py +1 -1
  229. {edsl-0.1.46.dist-info → edsl-0.1.48.dist-info}/METADATA +3 -2
  230. edsl-0.1.48.dist-info/RECORD +347 -0
  231. edsl/Base.py +0 -426
  232. edsl/BaseDiff.py +0 -260
  233. edsl/agents/InvigilatorBase.py +0 -260
  234. edsl/agents/PromptConstructor.py +0 -318
  235. edsl/auto/AutoStudy.py +0 -130
  236. edsl/auto/StageBase.py +0 -243
  237. edsl/auto/StageGenerateSurvey.py +0 -178
  238. edsl/auto/StageLabelQuestions.py +0 -125
  239. edsl/auto/StagePersona.py +0 -61
  240. edsl/auto/StagePersonaDimensionValueRanges.py +0 -88
  241. edsl/auto/StagePersonaDimensionValues.py +0 -74
  242. edsl/auto/StagePersonaDimensions.py +0 -69
  243. edsl/auto/StageQuestions.py +0 -74
  244. edsl/auto/SurveyCreatorPipeline.py +0 -21
  245. edsl/auto/utilities.py +0 -218
  246. edsl/base/Base.py +0 -279
  247. edsl/coop/PriceFetcher.py +0 -54
  248. edsl/data/Cache.py +0 -580
  249. edsl/data/CacheEntry.py +0 -230
  250. edsl/data/SQLiteDict.py +0 -292
  251. edsl/data/__init__.py +0 -5
  252. edsl/data/orm.py +0 -10
  253. edsl/exceptions/cache.py +0 -5
  254. edsl/exceptions/coop.py +0 -14
  255. edsl/exceptions/data.py +0 -14
  256. edsl/exceptions/scenarios.py +0 -29
  257. edsl/jobs/Answers.py +0 -43
  258. edsl/jobs/JobsPrompts.py +0 -354
  259. edsl/jobs/buckets/BucketCollection.py +0 -134
  260. edsl/jobs/buckets/ModelBuckets.py +0 -65
  261. edsl/jobs/buckets/TokenBucket.py +0 -283
  262. edsl/jobs/buckets/TokenBucketClient.py +0 -191
  263. edsl/jobs/interviews/Interview.py +0 -395
  264. edsl/jobs/interviews/InterviewExceptionCollection.py +0 -99
  265. edsl/jobs/interviews/InterviewStatisticsCollection.py +0 -25
  266. edsl/jobs/runners/JobsRunnerAsyncio.py +0 -163
  267. edsl/jobs/runners/JobsRunnerStatusData.py +0 -0
  268. edsl/jobs/tasks/TaskCreators.py +0 -64
  269. edsl/jobs/tasks/TaskStatusLog.py +0 -23
  270. edsl/jobs/tokens/InterviewTokenUsage.py +0 -27
  271. edsl/language_models/LanguageModel.py +0 -635
  272. edsl/language_models/ServiceDataSources.py +0 -0
  273. edsl/language_models/key_management/KeyLookup.py +0 -63
  274. edsl/language_models/key_management/KeyLookupCollection.py +0 -38
  275. edsl/language_models/key_management/models.py +0 -137
  276. edsl/questions/QuestionBase.py +0 -539
  277. edsl/questions/QuestionFreeText.py +0 -130
  278. edsl/questions/derived/QuestionLikertFive.py +0 -76
  279. edsl/results/DatasetExportMixin.py +0 -911
  280. edsl/results/ResultsExportMixin.py +0 -45
  281. edsl/results/TextEditor.py +0 -50
  282. edsl/results/results_fetch_mixin.py +0 -33
  283. edsl/results/results_tools_mixin.py +0 -98
  284. edsl/scenarios/DocumentChunker.py +0 -104
  285. edsl/scenarios/FileStore.py +0 -564
  286. edsl/scenarios/Scenario.py +0 -548
  287. edsl/scenarios/ScenarioHtmlMixin.py +0 -65
  288. edsl/scenarios/ScenarioListExportMixin.py +0 -45
  289. edsl/scenarios/handlers/latex.py +0 -5
  290. edsl/shared.py +0 -1
  291. edsl/surveys/Survey.py +0 -1306
  292. edsl/surveys/SurveyQualtricsImport.py +0 -284
  293. edsl/surveys/SurveyToApp.py +0 -141
  294. edsl/surveys/instructions/__init__.py +0 -0
  295. edsl/tools/__init__.py +0 -1
  296. edsl/tools/clusters.py +0 -192
  297. edsl/tools/embeddings.py +0 -27
  298. edsl/tools/embeddings_plotting.py +0 -118
  299. edsl/tools/plotting.py +0 -112
  300. edsl/tools/summarize.py +0 -18
  301. edsl/utilities/data/Registry.py +0 -6
  302. edsl/utilities/data/__init__.py +0 -1
  303. edsl/utilities/data/scooter_results.json +0 -1
  304. edsl-0.1.46.dist-info/RECORD +0 -366
  305. /edsl/coop/{CoopFunctionsMixin.py → coop_functions.py} +0 -0
  306. /edsl/{results → dataset/display}/CSSParameterizer.py +0 -0
  307. /edsl/{language_models/key_management → dataset/display}/__init__.py +0 -0
  308. /edsl/{results → dataset/display}/table_data_class.py +0 -0
  309. /edsl/{results → dataset/display}/table_display.css +0 -0
  310. /edsl/{results/ResultsGGMixin.py → dataset/r/ggplot.py} +0 -0
  311. /edsl/{results → dataset}/tree_explore.py +0 -0
  312. /edsl/{surveys/instructions/ChangeInstruction.py → instructions/change_instruction.py} +0 -0
  313. /edsl/{jobs/interviews → interviews}/interview_status_enum.py +0 -0
  314. /edsl/jobs/{runners/JobsRunnerStatus.py → jobs_runner_status.py} +0 -0
  315. /edsl/language_models/{PriceManager.py → price_manager.py} +0 -0
  316. /edsl/language_models/{fake_openai_call.py → unused/fake_openai_call.py} +0 -0
  317. /edsl/language_models/{fake_openai_service.py → unused/fake_openai_service.py} +0 -0
  318. /edsl/notebooks/{NotebookToLaTeX.py → notebook_to_latex.py} +0 -0
  319. /edsl/{exceptions/questions.py → questions/exceptions.py} +0 -0
  320. /edsl/questions/{SimpleAskMixin.py → simple_ask_mixin.py} +0 -0
  321. /edsl/surveys/{Memory.py → memory/memory.py} +0 -0
  322. /edsl/surveys/{MemoryManagement.py → memory/memory_management.py} +0 -0
  323. /edsl/surveys/{SurveyCSS.py → survey_css.py} +0 -0
  324. /edsl/{jobs/tokens/TokenUsage.py → tokens/token_usage.py} +0 -0
  325. /edsl/{results/MarkdownToDocx.py → utilities/markdown_to_docx.py} +0 -0
  326. /edsl/{TemplateLoader.py → utilities/template_loader.py} +0 -0
  327. {edsl-0.1.46.dist-info → edsl-0.1.48.dist-info}/LICENSE +0 -0
  328. {edsl-0.1.46.dist-info → edsl-0.1.48.dist-info}/WHEEL +0 -0
@@ -0,0 +1,1303 @@
1
+ """Base class for all classes in the package.
2
+
3
+ This module provides the foundation for all classes in the EDSL framework, implementing core
4
+ functionality such as serialization, persistence, rich representation, and object comparison.
5
+ The Base class combines several mixins that provide different aspects of functionality:
6
+ - RepresentationMixin: Handles object display and visualization
7
+ - PersistenceMixin: Manages saving/loading objects and cloud operations
8
+ - DiffMethodsMixin: Enables object comparison and differencing
9
+ - HashingMixin: Provides consistent hashing and equality operations
10
+
11
+ Classes inheriting from Base get a rich set of capabilities "for free" including
12
+ JSON/YAML serialization, file persistence, pretty printing, and object comparison.
13
+ """
14
+
15
+ from abc import ABC, abstractmethod, ABCMeta
16
+ import gzip
17
+ import json
18
+ from typing import Any, Optional, Union
19
+ from uuid import UUID
20
+ import difflib
21
+ import json
22
+ from typing import Any, Dict, Tuple
23
+ from collections import UserList
24
+ import inspect
25
+
26
+ class BaseException(Exception):
27
+ """Base exception class for all EDSL exceptions.
28
+
29
+ This class extends the standard Python Exception class to provide more helpful error messages
30
+ by including links to relevant documentation and example notebooks when available.
31
+
32
+ Attributes:
33
+ relevant_doc: URL to documentation explaining this type of exception
34
+ relevant_notebook: Optional URL to a notebook with usage examples
35
+ """
36
+ relevant_doc = "https://docs.expectedparrot.com/"
37
+
38
+ def __init__(self, message, *, show_docs=True):
39
+ """Initialize a new BaseException with formatted error message.
40
+
41
+ Args:
42
+ message: The primary error message
43
+ show_docs: If True, append documentation links to the error message
44
+ """
45
+ # Format main error message
46
+ formatted_message = [message.strip()]
47
+
48
+ # Add documentation links if requested
49
+ if show_docs:
50
+ if hasattr(self, "relevant_doc"):
51
+ formatted_message.append(
52
+ f"\nFor more information, see:\n{self.relevant_doc}"
53
+ )
54
+ if hasattr(self, "relevant_notebook"):
55
+ formatted_message.append(
56
+ f"\nFor a usage example, see:\n{self.relevant_notebook}"
57
+ )
58
+
59
+ # Join with double newlines for clear separation
60
+ final_message = "\n\n".join(formatted_message)
61
+ super().__init__(final_message)
62
+
63
+
64
+ class DisplayJSON:
65
+ """Display a dictionary as JSON."""
66
+
67
+ def __init__(self, input_dict: dict):
68
+ self.text = json.dumps(input_dict, indent=4)
69
+
70
+ def __repr__(self):
71
+ return self.text
72
+
73
+
74
+ class DisplayYAML:
75
+ """Display a dictionary as YAML."""
76
+
77
+ def __init__(self, input_dict: dict):
78
+ import yaml
79
+
80
+ self.text = yaml.dump(input_dict)
81
+
82
+ def __repr__(self):
83
+ return self.text
84
+
85
+
86
+ class PersistenceMixin:
87
+ """Mixin for saving and loading objects to and from files.
88
+
89
+ This mixin provides methods for serializing objects to various formats (JSON, YAML),
90
+ saving to and loading from files, and interacting with cloud storage. It enables
91
+ persistence operations like duplicating objects and uploading/downloading from the
92
+ EDSL cooperative platform.
93
+ """
94
+
95
+ def duplicate(self, add_edsl_version=False):
96
+ """Create and return a deep copy of the object.
97
+
98
+ Args:
99
+ add_edsl_version: Whether to include EDSL version information in the duplicated object
100
+
101
+ Returns:
102
+ A new instance of the same class with identical properties
103
+ """
104
+ return self.from_dict(self.to_dict(add_edsl_version=False))
105
+
106
+ @classmethod
107
+ def help(cls):
108
+ """Display the class documentation string.
109
+
110
+ This is a convenience method to quickly access the docstring of the class.
111
+
112
+ Returns:
113
+ None, but prints the class docstring to stdout
114
+ """
115
+ print(cls.__doc__)
116
+
117
+ def push(
118
+ self,
119
+ description: Optional[str] = None,
120
+ alias: Optional[str] = None,
121
+ visibility: Optional[str] = "unlisted",
122
+ expected_parrot_url: Optional[str] = None,
123
+ ):
124
+ """Upload this object to the EDSL cooperative platform.
125
+
126
+ This method serializes the object and posts it to the EDSL coop service,
127
+ making it accessible to others or for your own use across sessions.
128
+
129
+ Args:
130
+ description: Optional text description of the object
131
+ alias: Optional human-readable identifier for the object
132
+ visibility: Access level setting ("private", "unlisted", or "public")
133
+ expected_parrot_url: Optional custom URL for the coop service
134
+
135
+ Returns:
136
+ The response from the coop service containing the object's unique identifier
137
+ """
138
+ from edsl.coop import Coop
139
+
140
+ c = Coop(url=expected_parrot_url)
141
+ return c.create(self, description, alias, visibility)
142
+
143
+ def to_yaml(self, add_edsl_version=False, filename: str = None) -> Union[str, None]:
144
+ """Convert the object to YAML format.
145
+
146
+ Serializes the object to YAML format and optionally writes it to a file.
147
+
148
+ Args:
149
+ add_edsl_version: Whether to include EDSL version information
150
+ filename: If provided, write the YAML to this file path
151
+
152
+ Returns:
153
+ str: The YAML string representation if no filename is provided
154
+ None: If written to file
155
+ """
156
+ import yaml
157
+
158
+ output = yaml.dump(self.to_dict(add_edsl_version=add_edsl_version))
159
+ if not filename:
160
+ return output
161
+
162
+ with open(filename, "w") as f:
163
+ f.write(output)
164
+
165
+ @classmethod
166
+ def from_yaml(cls, yaml_str: Optional[str] = None, filename: Optional[str] = None):
167
+ """Create an instance from YAML data.
168
+
169
+ Deserializes a YAML string or file into a new instance of the class.
170
+
171
+ Args:
172
+ yaml_str: YAML string containing object data
173
+ filename: Path to a YAML file containing object data
174
+
175
+ Returns:
176
+ A new instance of the class populated with the deserialized data
177
+
178
+ Raises:
179
+ ValueError: If neither yaml_str nor filename is provided
180
+ """
181
+ if yaml_str is None and filename is not None:
182
+ with open(filename, "r") as f:
183
+ yaml_str = f.read()
184
+ return cls.from_yaml(yaml_str=yaml_str)
185
+ elif yaml_str and filename is None:
186
+ import yaml
187
+
188
+ d = yaml.load(yaml_str, Loader=yaml.FullLoader)
189
+ return cls.from_dict(d)
190
+ else:
191
+ raise ValueError("Either yaml_str or filename must be provided.")
192
+
193
+ def create_download_link(self):
194
+ """Generate a downloadable link for this object.
195
+
196
+ Creates a temporary file containing the serialized object and generates
197
+ a download link that can be shared with others.
198
+
199
+ Returns:
200
+ str: A URL that can be used to download the object
201
+ """
202
+ from tempfile import NamedTemporaryFile
203
+ from edsl.scenarios import FileStore
204
+
205
+ with NamedTemporaryFile(suffix=".json.gz") as f:
206
+ self.save(f.name)
207
+ print(f.name)
208
+ fs = FileStore(path=f.name)
209
+ return fs.create_link()
210
+
211
+ @classmethod
212
+ def pull(
213
+ cls,
214
+ url_or_uuid: Optional[Union[str, UUID]] = None,
215
+ ):
216
+ """Pull the object from coop.
217
+
218
+ Args:
219
+ url_or_uuid: Either a UUID string or a URL pointing to the object
220
+ """
221
+ from edsl.coop import Coop
222
+ from edsl.coop import ObjectRegistry
223
+
224
+ object_type = ObjectRegistry.get_object_type_by_edsl_class(cls)
225
+ coop = Coop()
226
+
227
+ return coop.get(url_or_uuid, expected_object_type=object_type)
228
+
229
+ @classmethod
230
+ def delete(cls, url_or_uuid: Union[str, UUID]) -> None:
231
+ """Delete the object from coop."""
232
+ from edsl.coop import Coop
233
+
234
+ coop = Coop()
235
+
236
+ return coop.delete(url_or_uuid)
237
+
238
+ @classmethod
239
+ def patch_cls(
240
+ cls,
241
+ url_or_uuid: Union[str, UUID],
242
+ description: Optional[str] = None,
243
+ value: Optional[Any] = None,
244
+ visibility: Optional[str] = None,
245
+ ):
246
+ """
247
+ Patch an uploaded object's attributes (class method version).
248
+ - `description` changes the description of the object on Coop
249
+ - `value` changes the value of the object on Coop. **has to be an EDSL object**
250
+ - `visibility` changes the visibility of the object on Coop
251
+ """
252
+ from edsl.coop import Coop
253
+
254
+ coop = Coop()
255
+
256
+ return coop.patch(
257
+ url_or_uuid=url_or_uuid,
258
+ description=description,
259
+ value=value,
260
+ visibility=visibility,
261
+ )
262
+
263
+ class ClassOrInstanceMethod:
264
+ """Descriptor that allows a method to be called as both a class method and an instance method."""
265
+
266
+ def __init__(self, func):
267
+ self.func = func
268
+
269
+ def __get__(self, obj, objtype=None):
270
+ if obj is None:
271
+ # Called as a class method
272
+ def wrapper(*args, **kwargs):
273
+ return self.func(objtype, *args, **kwargs)
274
+
275
+ return wrapper
276
+ else:
277
+ # Called as an instance method
278
+ def wrapper(*args, **kwargs):
279
+ return self.func(obj, *args, **kwargs)
280
+
281
+ return wrapper
282
+
283
+ @ClassOrInstanceMethod
284
+ def patch(
285
+ self_or_cls,
286
+ url_or_uuid: Union[str, UUID],
287
+ description: Optional[str] = None,
288
+ value: Optional[Any] = None,
289
+ visibility: Optional[str] = None,
290
+ ):
291
+ """
292
+ Patch an uploaded object's attributes.
293
+
294
+ When called as a class method:
295
+ - Requires explicit `value` parameter
296
+
297
+ When called as an instance method:
298
+ - Uses the instance itself as the `value` parameter
299
+
300
+ Parameters:
301
+ - `id_or_url`: ID or URL of the object to patch
302
+ - `description`: changes the description of the object on Coop
303
+ - `value`: changes the value of the object on Coop (required for class method)
304
+ - `visibility`: changes the visibility of the object on Coop
305
+ """
306
+
307
+ # Check if this is being called as a class method
308
+ if isinstance(self_or_cls, type):
309
+ # This is a class method call
310
+ cls = self_or_cls
311
+ return cls.patch_cls(
312
+ url_or_uuid=url_or_uuid,
313
+ description=description,
314
+ value=value,
315
+ visibility=visibility,
316
+ )
317
+ else:
318
+ # This is an instance method call
319
+ instance = self_or_cls
320
+ cls_type = instance.__class__
321
+
322
+ # Use the instance as the value if not explicitly provided
323
+ if value is None:
324
+ value = instance
325
+ else:
326
+ pass
327
+
328
+ return cls_type.patch_cls(
329
+ url_or_uuid=url_or_uuid,
330
+ description=description,
331
+ value=value,
332
+ visibility=visibility,
333
+ )
334
+
335
+ @classmethod
336
+ def search(cls, query):
337
+ """Search for objects on coop."""
338
+ from edsl.coop import Coop
339
+
340
+ c = Coop()
341
+ return c.search(cls, query)
342
+
343
+ def store(self, d: dict, key_name: Optional[str] = None):
344
+ if key_name is None:
345
+ index = len(d)
346
+ else:
347
+ index = key_name
348
+ d[index] = self
349
+
350
+ def save(self, filename, compress=True):
351
+ """Save the object to a file as JSON with optional compression.
352
+
353
+ Serializes the object to JSON and writes it to the specified file.
354
+ By default, the file will be compressed using gzip. File extensions
355
+ are handled automatically.
356
+
357
+ Args:
358
+ filename: Path where the file should be saved
359
+ compress: If True, compress the file using gzip (default: True)
360
+
361
+ Returns:
362
+ None
363
+
364
+ Examples:
365
+ >>> obj.save("my_object.json.gz") # Compressed
366
+ >>> obj.save("my_object.json", compress=False) # Uncompressed
367
+ """
368
+ if filename.endswith("json.gz"):
369
+ import warnings
370
+
371
+ filename = filename[:-8]
372
+ if filename.endswith("json"):
373
+ filename = filename[:-5]
374
+
375
+ if compress:
376
+ full_file_name = filename + ".json.gz"
377
+ with gzip.open(full_file_name, "wb") as f:
378
+ f.write(json.dumps(self.to_dict()).encode("utf-8"))
379
+ else:
380
+ full_file_name = filename + ".json"
381
+ with open(filename + ".json", "w") as f:
382
+ f.write(json.dumps(self.to_dict()))
383
+
384
+ print("Saved to", full_file_name)
385
+
386
+ @staticmethod
387
+ def open_compressed_file(filename):
388
+ """Read and parse a compressed JSON file.
389
+
390
+ Args:
391
+ filename: Path to a gzipped JSON file
392
+
393
+ Returns:
394
+ dict: The parsed JSON content
395
+ """
396
+ with gzip.open(filename, "rb") as f:
397
+ file_contents = f.read()
398
+ file_contents_decoded = file_contents.decode("utf-8")
399
+ d = json.loads(file_contents_decoded)
400
+ return d
401
+
402
+ @staticmethod
403
+ def open_regular_file(filename):
404
+ """Read and parse an uncompressed JSON file.
405
+
406
+ Args:
407
+ filename: Path to a JSON file
408
+
409
+ Returns:
410
+ dict: The parsed JSON content
411
+ """
412
+ with open(filename, "r") as f:
413
+ d = json.loads(f.read())
414
+ return d
415
+
416
+ @classmethod
417
+ def load(cls, filename):
418
+ """Load the object from a JSON file (compressed or uncompressed).
419
+
420
+ This method deserializes an object from a file, automatically detecting
421
+ whether the file is compressed with gzip or not.
422
+
423
+ Args:
424
+ filename: Path to the file to load
425
+
426
+ Returns:
427
+ An instance of the class populated with data from the file
428
+
429
+ Raises:
430
+ Various exceptions may be raised if the file doesn't exist or contains invalid data
431
+ """
432
+ if filename.endswith("json.gz"):
433
+ d = cls.open_compressed_file(filename)
434
+ elif filename.endswith("json"):
435
+ d = cls.open_regular_file(filename)
436
+ else:
437
+ try:
438
+ d = cls.open_compressed_file(filename + ".json.gz")
439
+ except:
440
+ d = cls.open_regular_file(filename + ".json")
441
+ # finally:
442
+ # raise ValueError("File must be a json or json.gz file")
443
+
444
+ return cls.from_dict(d)
445
+
446
+
447
+ class RegisterSubclassesMeta(ABCMeta):
448
+ """Metaclass for automatically registering all subclasses.
449
+
450
+ This metaclass maintains a registry of all classes that inherit from Base,
451
+ allowing for dynamic discovery of available classes and capabilities like
452
+ automatic deserialization. When a new class is defined with Base as its
453
+ parent, this metaclass automatically adds it to the registry.
454
+ """
455
+
456
+ _registry = {}
457
+
458
+ def __init__(cls, name, bases, nmspc):
459
+ """Register the class in the registry upon creation.
460
+
461
+ Args:
462
+ name: The name of the class being created
463
+ bases: The base classes of the class being created
464
+ nmspc: The namespace of the class being created
465
+ """
466
+ super(RegisterSubclassesMeta, cls).__init__(name, bases, nmspc)
467
+ if cls.__name__ != "Base":
468
+ RegisterSubclassesMeta._registry[cls.__name__] = cls
469
+
470
+ @staticmethod
471
+ def get_registry(exclude_classes: Optional[list] = None):
472
+ """Get the registry of all registered subclasses.
473
+
474
+ Args:
475
+ exclude_classes: Optional list of class names to exclude from the result
476
+
477
+ Returns:
478
+ dict: A dictionary mapping class names to class objects
479
+ """
480
+ if exclude_classes is None:
481
+ exclude_classes = []
482
+ return {
483
+ k: v
484
+ for k, v in dict(RegisterSubclassesMeta._registry).items()
485
+ if k not in exclude_classes
486
+ }
487
+
488
+
489
+ class DiffMethodsMixin:
490
+ """Mixin that adds the ability to compute differences between objects.
491
+
492
+ This mixin provides operator overloads that enable convenient comparison and
493
+ differencing between objects of the same class.
494
+ """
495
+
496
+ def __sub__(self, other):
497
+ """Calculate the difference between this object and another.
498
+
499
+ This overloads the subtraction operator (-) to provide an intuitive way
500
+ to compare objects and find their differences.
501
+
502
+ Args:
503
+ other: Another object to compare against this one
504
+
505
+ Returns:
506
+ BaseDiff: An object representing the differences between the two objects
507
+ """
508
+ from edsl.base import BaseDiff
509
+
510
+ return BaseDiff(self, other)
511
+
512
+
513
+ def is_iterable(obj):
514
+ """Check if an object is iterable.
515
+
516
+ Args:
517
+ obj: The object to check
518
+
519
+ Returns:
520
+ bool: True if the object is iterable, False otherwise
521
+ """
522
+ try:
523
+ iter(obj)
524
+ except TypeError:
525
+ return False
526
+ return True
527
+
528
+
529
+ class RepresentationMixin:
530
+ """Mixin that provides rich display and representation capabilities.
531
+
532
+ This mixin enhances objects with methods for displaying their contents in various
533
+ formats including JSON, HTML tables, and rich terminal output. It improves the
534
+ user experience when working with EDSL objects in notebooks and terminals.
535
+ """
536
+
537
+ def json(self):
538
+ """Get a parsed JSON representation of this object.
539
+
540
+ Returns:
541
+ dict: The object's data as a Python dictionary
542
+ """
543
+ return json.loads(json.dumps(self.to_dict(add_edsl_version=False)))
544
+
545
+ def to_dataset(self):
546
+ """Convert this object to a Dataset for advanced data operations.
547
+
548
+ Returns:
549
+ Dataset: A Dataset object containing this object's data
550
+ """
551
+ from edsl.dataset import Dataset
552
+
553
+ return Dataset.from_edsl_object(self)
554
+
555
+ def view(self):
556
+ """Display an interactive visualization of this object.
557
+
558
+ Returns:
559
+ The result of the dataset's view method
560
+ """
561
+ return self.to_dataset().view()
562
+
563
+ # def print(self, format="rich"):
564
+ # return self.to_dataset().table()
565
+
566
+ def display_dict(self):
567
+ """Create a flattened dictionary representation for display purposes.
568
+
569
+ This method creates a flattened view of nested structures using colon notation
570
+ in keys to represent hierarchy.
571
+
572
+ Returns:
573
+ dict: A flattened dictionary suitable for display
574
+ """
575
+ display_dict = {}
576
+ d = self.to_dict(add_edsl_version=False)
577
+ for key, value in d.items():
578
+ if isinstance(value, dict):
579
+ for k, v in value.items():
580
+ display_dict[f"{key}:{k}"] = v
581
+ elif isinstance(value, list):
582
+ for i, v in enumerate(value):
583
+ display_dict[f"{key}:{i}"] = v
584
+ else:
585
+ display_dict[key] = value
586
+ return display_dict
587
+
588
+ def print(self, format="rich"):
589
+ """Print a formatted table representation of this object.
590
+
591
+ Args:
592
+ format: The output format (currently only 'rich' is supported)
593
+
594
+ Returns:
595
+ None, but prints a formatted table to the console
596
+ """
597
+ from rich.table import Table
598
+ from rich.console import Console
599
+
600
+ table = Table(title=self.__class__.__name__)
601
+ table.add_column("Key", style="bold")
602
+ table.add_column("Value", style="bold")
603
+
604
+ for key, value in self.display_dict().items():
605
+ table.add_row(key, str(value))
606
+
607
+ console = Console(record=True)
608
+ console.print(table)
609
+
610
+ def _repr_html_(self):
611
+ """Generate an HTML representation for Jupyter notebooks.
612
+
613
+ This method is automatically called by Jupyter to render the object
614
+ as HTML in notebook cells.
615
+
616
+ Returns:
617
+ str: HTML representation of the object
618
+ """
619
+ from edsl.dataset.display.table_display import TableDisplay
620
+
621
+ if hasattr(self, "_summary"):
622
+ summary_dict = self._summary()
623
+ summary_line = "".join([f" {k}: {v};" for k, v in summary_dict.items()])
624
+ class_name = self.__class__.__name__
625
+ docs = getattr(self, "__documentation__", "")
626
+ return (
627
+ "<p>"
628
+ + f"<a href='{docs}'>{class_name}</a>"
629
+ + summary_line
630
+ + "</p>"
631
+ + self.table()._repr_html_()
632
+ )
633
+ else:
634
+ class_name = self.__class__.__name__
635
+ documentation = getattr(self, "__documentation__", "")
636
+ summary_line = "<p>" + f"<a href='{documentation}'>{class_name}</a>" + "</p>"
637
+ display_dict = self.display_dict()
638
+ return (
639
+ summary_line
640
+ + TableDisplay.from_dictionary_wide(display_dict)._repr_html_()
641
+ )
642
+
643
+ def __str__(self):
644
+ """Return the string representation of the object.
645
+
646
+ Returns:
647
+ str: String representation of the object
648
+ """
649
+ return self.__repr__()
650
+
651
+
652
+ class HashingMixin:
653
+ """Mixin that provides consistent hashing and equality operations.
654
+
655
+ This mixin implements __hash__ and __eq__ methods to enable using EDSL objects
656
+ in sets and as dictionary keys. The hash is based on the object's serialized content,
657
+ so two objects with identical content will be considered equal.
658
+ """
659
+
660
+ def __hash__(self) -> int:
661
+ """Generate a hash value for this object based on its content.
662
+
663
+ The hash is computed from the serialized dictionary representation of the object,
664
+ excluding any version information.
665
+
666
+ Returns:
667
+ int: A hash value for the object
668
+ """
669
+ from edsl.utilities.utilities import dict_hash
670
+
671
+ return dict_hash(self.to_dict(add_edsl_version=False))
672
+
673
+ def __eq__(self, other):
674
+ """Compare this object with another for equality.
675
+
676
+ Two objects are considered equal if they have the same hash value,
677
+ which means they have identical content.
678
+
679
+ Args:
680
+ other: Another object to compare with this one
681
+
682
+ Returns:
683
+ bool: True if the objects are equal, False otherwise
684
+ """
685
+ return hash(self) == hash(other)
686
+
687
+
688
+ class Base(
689
+ RepresentationMixin,
690
+ PersistenceMixin,
691
+ DiffMethodsMixin,
692
+ HashingMixin,
693
+ ABC,
694
+ metaclass=RegisterSubclassesMeta,
695
+ ):
696
+ """Base class for all classes in the EDSL package.
697
+
698
+ This abstract base class combines several mixins to provide a rich set of functionality
699
+ to all EDSL objects. It defines the core interface that all EDSL objects must implement,
700
+ including serialization, deserialization, and code generation.
701
+
702
+ All EDSL classes should inherit from this class to ensure consistent behavior
703
+ and capabilities across the framework.
704
+ """
705
+
706
+ def keys(self):
707
+ """Get the key names in the object's dictionary representation.
708
+
709
+ This method returns all the keys in the serialized form of the object,
710
+ excluding metadata keys like version information.
711
+
712
+ Returns:
713
+ list: A list of key names
714
+ """
715
+ _keys = list(self.to_dict().keys())
716
+ if "edsl_version" in _keys:
717
+ _keys.remove("edsl_version")
718
+ if "edsl_class_name" in _keys:
719
+ _keys.remove("edsl_class_name")
720
+ return _keys
721
+
722
+ def values(self):
723
+ """Get the values in the object's dictionary representation.
724
+
725
+ Returns:
726
+ set: A set containing all the values in the object
727
+ """
728
+ data = self.to_dict()
729
+ keys = self.keys()
730
+ return {data[key] for key in keys}
731
+
732
+ @abstractmethod
733
+ def example():
734
+ """Create an example instance of this class.
735
+
736
+ This method should be implemented by all subclasses to provide
737
+ a convenient way to create example objects for testing and demonstration.
738
+
739
+ Returns:
740
+ An instance of the class with sample data
741
+ """
742
+ raise NotImplementedError("This method is not implemented yet.")
743
+
744
+ def json(self):
745
+ """Get a formatted JSON representation of this object.
746
+
747
+ Returns:
748
+ DisplayJSON: A displayable JSON representation
749
+ """
750
+ return DisplayJSON(self.to_dict(add_edsl_version=False))
751
+
752
+ def yaml(self):
753
+ """Get a formatted YAML representation of this object.
754
+
755
+ Returns:
756
+ DisplayYAML: A displayable YAML representation
757
+ """
758
+ import yaml
759
+ return DisplayYAML(self.to_dict(add_edsl_version=False))
760
+
761
+
762
+ @abstractmethod
763
+ def to_dict():
764
+ """Serialize this object to a dictionary.
765
+
766
+ This method must be implemented by all subclasses to provide a
767
+ standard way to serialize objects to dictionaries. The dictionary
768
+ should contain all the data needed to reconstruct the object.
769
+
770
+ Returns:
771
+ dict: A dictionary representation of the object
772
+ """
773
+ raise NotImplementedError("This method is not implemented yet.")
774
+
775
+ def to_json(self):
776
+ """Serialize this object to a JSON string.
777
+
778
+ Returns:
779
+ str: A JSON string representation of the object
780
+ """
781
+ return json.dumps(self.to_dict())
782
+
783
+ def store(self, d: dict, key_name: Optional[str] = None):
784
+ """Store this object in a dictionary with an optional key.
785
+
786
+ Args:
787
+ d: The dictionary in which to store the object
788
+ key_name: Optional key to use (defaults to the length of the dictionary)
789
+
790
+ Returns:
791
+ None
792
+ """
793
+ if key_name is None:
794
+ index = len(d)
795
+ else:
796
+ index = key_name
797
+ d[index] = self
798
+
799
+ @abstractmethod
800
+ def from_dict():
801
+ """Create an instance from a dictionary.
802
+
803
+ This class method must be implemented by all subclasses to provide a
804
+ standard way to deserialize objects from dictionaries.
805
+
806
+ Returns:
807
+ An instance of the class populated with data from the dictionary
808
+ """
809
+ raise NotImplementedError("This method is not implemented yet.")
810
+
811
+ @abstractmethod
812
+ def code():
813
+ """Generate Python code that recreates this object.
814
+
815
+ This method must be implemented by all subclasses to provide a way to
816
+ generate executable Python code that can recreate the object.
817
+
818
+ Returns:
819
+ str: Python code that, when executed, creates an equivalent object
820
+ """
821
+ raise NotImplementedError("This method is not implemented yet.")
822
+
823
+ def show_methods(self, show_docstrings=True):
824
+ """Display all public methods available on this object.
825
+
826
+ This utility method helps explore the capabilities of an object by listing
827
+ all its public methods and optionally their documentation.
828
+
829
+ Args:
830
+ show_docstrings: If True, print method names with docstrings;
831
+ if False, return the list of method names
832
+
833
+ Returns:
834
+ None or list: If show_docstrings is True, prints methods and returns None.
835
+ If show_docstrings is False, returns a list of method names.
836
+ """
837
+ public_methods_with_docstrings = [
838
+ (method, getattr(self, method).__doc__)
839
+ for method in dir(self)
840
+ if callable(getattr(self, method)) and not method.startswith("_")
841
+ ]
842
+ if show_docstrings:
843
+ for method, documentation in public_methods_with_docstrings:
844
+ print(f"{method}: {documentation}")
845
+ else:
846
+ return [x[0] for x in public_methods_with_docstrings]
847
+
848
+
849
+ class BaseDiffCollection(UserList):
850
+ """A collection of difference objects that can be applied in sequence.
851
+
852
+ This class represents a series of differences between objects that can be
853
+ applied sequentially to transform one object into another through several steps.
854
+ """
855
+
856
+ def __init__(self, diffs=None):
857
+ """Initialize a new BaseDiffCollection.
858
+
859
+ Args:
860
+ diffs: Optional list of BaseDiff objects to include in the collection
861
+ """
862
+ if diffs is None:
863
+ diffs = []
864
+ super().__init__(diffs)
865
+
866
+ def apply(self, obj: Any):
867
+ """Apply all diffs in the collection to an object in sequence.
868
+
869
+ Args:
870
+ obj: The object to transform
871
+
872
+ Returns:
873
+ The transformed object after applying all diffs
874
+ """
875
+ for diff in self:
876
+ obj = diff.apply(obj)
877
+ return obj
878
+
879
+ def add_diff(self, diff) -> "BaseDiffCollection":
880
+ """Add a new diff to the collection.
881
+
882
+ Args:
883
+ diff: The BaseDiff object to add
884
+
885
+ Returns:
886
+ BaseDiffCollection: self, for method chaining
887
+ """
888
+ self.append(diff)
889
+ return self
890
+
891
+
892
+ class DummyObject:
893
+ """A simple class that can be used to wrap a dictionary for diffing purposes.
894
+
895
+ This utility class is used internally to compare dictionaries by adapting them
896
+ to the same interface as EDSL objects.
897
+ """
898
+
899
+ def __init__(self, object_dict):
900
+ """Initialize a new DummyObject.
901
+
902
+ Args:
903
+ object_dict: A dictionary to wrap
904
+ """
905
+ self.object_dict = object_dict
906
+
907
+ def to_dict(self):
908
+ """Get the wrapped dictionary.
909
+
910
+ Returns:
911
+ dict: The wrapped dictionary
912
+ """
913
+ return self.object_dict
914
+
915
+
916
+ class BaseDiff:
917
+ """Represents the differences between two EDSL objects.
918
+
919
+ This class computes and stores the differences between two objects in terms of:
920
+ - Added keys/values (present in obj2 but not in obj1)
921
+ - Removed keys/values (present in obj1 but not in obj2)
922
+ - Modified keys/values (present in both but with different values)
923
+
924
+ The differences can be displayed for inspection or applied to transform objects.
925
+ """
926
+
927
+ def __init__(
928
+ self, obj1: Any, obj2: Any, added=None, removed=None, modified=None, level=0
929
+ ):
930
+ """Initialize a new BaseDiff between two objects.
931
+
932
+ Args:
933
+ obj1: The first object (considered the "from" object)
934
+ obj2: The second object (considered the "to" object)
935
+ added: Optional pre-computed dict of added keys/values
936
+ removed: Optional pre-computed dict of removed keys/values
937
+ modified: Optional pre-computed dict of modified keys/values
938
+ level: Nesting level for diff display formatting
939
+ """
940
+ self.level = level
941
+
942
+ self.obj1 = obj1
943
+ self.obj2 = obj2
944
+
945
+ if "sort" in inspect.signature(obj1.to_dict).parameters:
946
+ self._dict1 = obj1.to_dict(sort=True)
947
+ self._dict2 = obj2.to_dict(sort=True)
948
+ else:
949
+ self._dict1 = obj1.to_dict()
950
+ self._dict2 = obj2.to_dict()
951
+ self._obj_class = type(obj1)
952
+
953
+ self.added = added
954
+ self.removed = removed
955
+ self.modified = modified
956
+
957
+ def __bool__(self):
958
+ """Determine if there are any differences between the objects.
959
+
960
+ Returns:
961
+ bool: True if there are differences, False if objects are identical
962
+ """
963
+ return bool(self.added or self.removed or self.modified)
964
+
965
+ @property
966
+ def added(self):
967
+ """Get keys and values present in obj2 but not in obj1.
968
+
969
+ Returns:
970
+ dict: Keys and values that were added
971
+ """
972
+ if self._added is None:
973
+ self._added = self._find_added()
974
+ return self._added
975
+
976
+ def __add__(self, other):
977
+ """Apply this diff to another object.
978
+
979
+ This overloads the + operator to allow applying diffs with a natural syntax.
980
+
981
+ Args:
982
+ other: The object to apply the diff to
983
+
984
+ Returns:
985
+ The transformed object
986
+ """
987
+ return self.apply(other)
988
+
989
+ @added.setter
990
+ def added(self, value):
991
+ """Set the added keys/values.
992
+
993
+ Args:
994
+ value: Dict of added keys/values or None to compute automatically
995
+ """
996
+ self._added = value if value is not None else self._find_added()
997
+
998
+ @property
999
+ def removed(self):
1000
+ """Get keys and values present in obj1 but not in obj2.
1001
+
1002
+ Returns:
1003
+ dict: Keys and values that were removed
1004
+ """
1005
+ if self._removed is None:
1006
+ self._removed = self._find_removed()
1007
+ return self._removed
1008
+
1009
+ @removed.setter
1010
+ def removed(self, value):
1011
+ """Set the removed keys/values.
1012
+
1013
+ Args:
1014
+ value: Dict of removed keys/values or None to compute automatically
1015
+ """
1016
+ self._removed = value if value is not None else self._find_removed()
1017
+
1018
+ @property
1019
+ def modified(self):
1020
+ """Get keys present in both objects but with different values.
1021
+
1022
+ Returns:
1023
+ dict: Keys and their old/new values that were modified
1024
+ """
1025
+ if self._modified is None:
1026
+ self._modified = self._find_modified()
1027
+ return self._modified
1028
+
1029
+ @modified.setter
1030
+ def modified(self, value):
1031
+ """Set the modified keys/values.
1032
+
1033
+ Args:
1034
+ value: Dict of modified keys/values or None to compute automatically
1035
+ """
1036
+ self._modified = value if value is not None else self._find_modified()
1037
+
1038
+ def _find_added(self) -> Dict[Any, Any]:
1039
+ """Find keys that exist in obj2 but not in obj1.
1040
+
1041
+ Returns:
1042
+ dict: Keys and values that were added
1043
+ """
1044
+ return {k: self._dict2[k] for k in self._dict2 if k not in self._dict1}
1045
+
1046
+ def _find_removed(self) -> Dict[Any, Any]:
1047
+ """Find keys that exist in obj1 but not in obj2.
1048
+
1049
+ Returns:
1050
+ dict: Keys and values that were removed
1051
+ """
1052
+ return {k: self._dict1[k] for k in self._dict1 if k not in self._dict2}
1053
+
1054
+ def _find_modified(self) -> Dict[Any, Tuple[Any, Any, str]]:
1055
+ """Find keys that exist in both objects but have different values.
1056
+
1057
+ The difference calculation is type-aware and handles strings, dictionaries,
1058
+ and lists specially to provide more detailed difference information.
1059
+
1060
+ Returns:
1061
+ dict: Keys mapped to tuples of (old_value, new_value, diff_details)
1062
+ """
1063
+ modified = {}
1064
+ for k in self._dict1:
1065
+ if k in self._dict2 and self._dict1[k] != self._dict2[k]:
1066
+ if isinstance(self._dict1[k], str) and isinstance(self._dict2[k], str):
1067
+ diff = self._diff_strings(self._dict1[k], self._dict2[k])
1068
+ modified[k] = (self._dict1[k], self._dict2[k], diff)
1069
+ elif isinstance(self._dict1[k], dict) and isinstance(
1070
+ self._dict2[k], dict
1071
+ ):
1072
+ diff = self._diff_dicts(self._dict1[k], self._dict2[k])
1073
+ modified[k] = (self._dict1[k], self._dict2[k], diff)
1074
+ elif isinstance(self._dict1[k], list) and isinstance(
1075
+ self._dict2[k], list
1076
+ ):
1077
+ d1 = dict(zip(range(len(self._dict1[k])), self._dict1[k]))
1078
+ d2 = dict(zip(range(len(self._dict2[k])), self._dict2[k]))
1079
+ diff = BaseDiff(
1080
+ DummyObject(d1), DummyObject(d2), level=self.level + 1
1081
+ )
1082
+ modified[k] = (self._dict1[k], self._dict2[k], diff)
1083
+ else:
1084
+ modified[k] = (self._dict1[k], self._dict2[k], "")
1085
+ return modified
1086
+
1087
+ @staticmethod
1088
+ def is_json(string_that_could_be_json: str) -> bool:
1089
+ """Check if a string is valid JSON.
1090
+
1091
+ Args:
1092
+ string_that_could_be_json: The string to check
1093
+
1094
+ Returns:
1095
+ bool: True if the string is valid JSON, False otherwise
1096
+ """
1097
+ try:
1098
+ json.loads(string_that_could_be_json)
1099
+ return True
1100
+ except json.JSONDecodeError:
1101
+ return False
1102
+
1103
+ def _diff_dicts(self, dict1: Dict[str, Any], dict2: Dict[str, Any]) -> "BaseDiff":
1104
+ """Calculate the differences between two dictionaries.
1105
+
1106
+ Args:
1107
+ dict1: The first dictionary
1108
+ dict2: The second dictionary
1109
+
1110
+ Returns:
1111
+ BaseDiff: A difference object between the dictionaries
1112
+ """
1113
+ diff = BaseDiff(DummyObject(dict1), DummyObject(dict2), level=self.level + 1)
1114
+ return diff
1115
+
1116
+ def _diff_strings(self, str1: str, str2: str) -> str:
1117
+ """Calculate the differences between two strings.
1118
+
1119
+ If both strings are valid JSON, they are compared as dictionaries.
1120
+ Otherwise, they are compared line by line.
1121
+
1122
+ Args:
1123
+ str1: The first string
1124
+ str2: The second string
1125
+
1126
+ Returns:
1127
+ Union[BaseDiff, Iterable[str]]: A diff object or line-by-line differences
1128
+ """
1129
+ if self.is_json(str1) and self.is_json(str2):
1130
+ diff = self._diff_dicts(json.loads(str1), json.loads(str2))
1131
+ return diff
1132
+ diff = difflib.ndiff(str1.splitlines(), str2.splitlines())
1133
+ return diff
1134
+
1135
+ def apply(self, obj: Any):
1136
+ """Apply this diff to transform an object.
1137
+
1138
+ This method applies the computed differences to an object, adding new keys,
1139
+ removing deleted keys, and updating modified values.
1140
+
1141
+ Args:
1142
+ obj: The object to transform
1143
+
1144
+ Returns:
1145
+ The transformed object
1146
+ """
1147
+ new_obj_dict = obj.to_dict()
1148
+ for k, v in self.added.items():
1149
+ new_obj_dict[k] = v
1150
+ for k in self.removed.keys():
1151
+ del new_obj_dict[k]
1152
+ for k, (v1, v2, diff) in self.modified.items():
1153
+ new_obj_dict[k] = v2
1154
+
1155
+ return obj.from_dict(new_obj_dict)
1156
+
1157
+ def to_dict(self) -> Dict[str, Any]:
1158
+ """Serialize this difference object to a dictionary.
1159
+
1160
+ Returns:
1161
+ dict: A dictionary representation of the differences
1162
+ """
1163
+ return {
1164
+ "added": self.added,
1165
+ "removed": self.removed,
1166
+ "modified": self.modified,
1167
+ "obj1": self._dict1,
1168
+ "obj2": self._dict2,
1169
+ "obj_class": self._obj_class.__name__,
1170
+ "level": self.level,
1171
+ }
1172
+
1173
+ @classmethod
1174
+ def from_dict(cls, diff_dict: Dict[str, Any], obj1: Any, obj2: Any):
1175
+ """Create a BaseDiff from a dictionary representation.
1176
+
1177
+ Args:
1178
+ diff_dict: Dictionary containing the difference data
1179
+ obj1: The first object
1180
+ obj2: The second object
1181
+
1182
+ Returns:
1183
+ BaseDiff: A new difference object
1184
+ """
1185
+ return cls(
1186
+ obj1=obj1,
1187
+ obj2=obj2,
1188
+ added=diff_dict["added"],
1189
+ removed=diff_dict["removed"],
1190
+ modified=diff_dict["modified"],
1191
+ level=diff_dict["level"],
1192
+ )
1193
+
1194
+ class Results(UserList):
1195
+ """Helper class for storing and formatting difference results.
1196
+
1197
+ This class extends UserList to provide indentation and formatting
1198
+ capabilities when displaying differences.
1199
+ """
1200
+
1201
+ def __init__(self, prepend=" ", level=0):
1202
+ """Initialize a new Results collection.
1203
+
1204
+ Args:
1205
+ prepend: The string to use for indentation
1206
+ level: The nesting level
1207
+ """
1208
+ super().__init__()
1209
+ self.prepend = prepend
1210
+ self.level = level
1211
+
1212
+ def append(self, item):
1213
+ """Add an item to the results with proper indentation.
1214
+
1215
+ Args:
1216
+ item: The string to add
1217
+ """
1218
+ super().append(self.prepend * self.level + item)
1219
+
1220
+ def __str__(self):
1221
+ """Generate a human-readable string representation of the differences.
1222
+
1223
+ Returns:
1224
+ str: A formatted string showing the differences
1225
+ """
1226
+ prepend = " "
1227
+ result = self.Results(level=self.level, prepend="\t")
1228
+ if self.added:
1229
+ result.append("Added keys and values:")
1230
+ for k, v in self.added.items():
1231
+ result.append(prepend + f" {k}: {v}")
1232
+ if self.removed:
1233
+ result.append("Removed keys and values:")
1234
+ for k, v in self.removed.items():
1235
+ result.append(f" {k}: {v}")
1236
+ if self.modified:
1237
+ result.append("Modified keys and values:")
1238
+ for k, (v1, v2, diff) in self.modified.items():
1239
+ result.append(f"Key: {k}:")
1240
+ result.append(f" Old value: {v1}")
1241
+ result.append(f" New value: {v2}")
1242
+ if diff:
1243
+ result.append(f" Diff:")
1244
+ try:
1245
+ for line in diff:
1246
+ result.append(f" {line}")
1247
+ except:
1248
+ result.append(f" {diff}")
1249
+ return "\n".join(result)
1250
+
1251
+ def __repr__(self):
1252
+ """Generate a developer-friendly string representation.
1253
+
1254
+ Returns:
1255
+ str: A representation that can be used to recreate the object
1256
+ """
1257
+ return (
1258
+ f"BaseDiff(obj1={self.obj1!r}, obj2={self.obj2!r}, added={self.added!r}, "
1259
+ f"removed={self.removed!r}, modified={self.modified!r})"
1260
+ )
1261
+
1262
+ def add_diff(self, diff) -> "BaseDiffCollection":
1263
+ """Combine this diff with another into a collection.
1264
+
1265
+ Args:
1266
+ diff: Another BaseDiff object
1267
+
1268
+ Returns:
1269
+ BaseDiffCollection: A collection containing both diffs
1270
+ """
1271
+ from edsl.base import BaseDiffCollection
1272
+ return BaseDiffCollection([self, diff])
1273
+
1274
+
1275
+ if __name__ == "__main__":
1276
+ import doctest
1277
+ doctest.testmod()
1278
+
1279
+ from edsl import Question
1280
+
1281
+ q_ft = Question.example("free_text")
1282
+ q_mc = Question.example("multiple_choice")
1283
+
1284
+ diff1 = q_ft - q_mc
1285
+ assert q_ft == q_mc + diff1
1286
+ assert q_ft == diff1.apply(q_mc)
1287
+
1288
+ # ## Test chain of diffs
1289
+ q0 = Question.example("free_text")
1290
+ q1 = q0.copy()
1291
+ q1.question_text = "Why is Buzzard's Bay so named?"
1292
+ diff1 = q1 - q0
1293
+ q2 = q1.copy()
1294
+ q2.question_name = "buzzard_bay"
1295
+ diff2 = q2 - q1
1296
+
1297
+ diff_chain = diff1.add_diff(diff2)
1298
+
1299
+ new_q2 = diff_chain.apply(q0)
1300
+ assert new_q2 == q2
1301
+
1302
+ new_q2 = diff_chain + q0
1303
+ assert new_q2 == q2