edsl 0.1.39.dev2__py3-none-any.whl → 0.1.39.dev3__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 (334) hide show
  1. edsl/Base.py +332 -385
  2. edsl/BaseDiff.py +260 -260
  3. edsl/TemplateLoader.py +24 -24
  4. edsl/__init__.py +49 -57
  5. edsl/__version__.py +1 -1
  6. edsl/agents/Agent.py +867 -1079
  7. edsl/agents/AgentList.py +413 -551
  8. edsl/agents/Invigilator.py +233 -285
  9. edsl/agents/InvigilatorBase.py +270 -254
  10. edsl/agents/PromptConstructor.py +354 -252
  11. edsl/agents/__init__.py +3 -2
  12. edsl/agents/descriptors.py +99 -99
  13. edsl/agents/prompt_helpers.py +129 -129
  14. edsl/auto/AutoStudy.py +117 -117
  15. edsl/auto/StageBase.py +230 -230
  16. edsl/auto/StageGenerateSurvey.py +178 -178
  17. edsl/auto/StageLabelQuestions.py +125 -125
  18. edsl/auto/StagePersona.py +61 -61
  19. edsl/auto/StagePersonaDimensionValueRanges.py +88 -88
  20. edsl/auto/StagePersonaDimensionValues.py +74 -74
  21. edsl/auto/StagePersonaDimensions.py +69 -69
  22. edsl/auto/StageQuestions.py +73 -73
  23. edsl/auto/SurveyCreatorPipeline.py +21 -21
  24. edsl/auto/utilities.py +224 -224
  25. edsl/base/Base.py +279 -279
  26. edsl/config.py +157 -177
  27. edsl/conversation/Conversation.py +290 -290
  28. edsl/conversation/car_buying.py +58 -59
  29. edsl/conversation/chips.py +95 -95
  30. edsl/conversation/mug_negotiation.py +81 -81
  31. edsl/conversation/next_speaker_utilities.py +93 -93
  32. edsl/coop/PriceFetcher.py +54 -54
  33. edsl/coop/__init__.py +2 -2
  34. edsl/coop/coop.py +1028 -1090
  35. edsl/coop/utils.py +131 -131
  36. edsl/data/Cache.py +555 -562
  37. edsl/data/CacheEntry.py +233 -230
  38. edsl/data/CacheHandler.py +149 -170
  39. edsl/data/RemoteCacheSync.py +78 -78
  40. edsl/data/SQLiteDict.py +292 -292
  41. edsl/data/__init__.py +4 -5
  42. edsl/data/orm.py +10 -10
  43. edsl/data_transfer_models.py +73 -74
  44. edsl/enums.py +175 -195
  45. edsl/exceptions/BaseException.py +21 -21
  46. edsl/exceptions/__init__.py +54 -54
  47. edsl/exceptions/agents.py +42 -54
  48. edsl/exceptions/cache.py +5 -5
  49. edsl/exceptions/configuration.py +16 -16
  50. edsl/exceptions/coop.py +10 -10
  51. edsl/exceptions/data.py +14 -14
  52. edsl/exceptions/general.py +34 -34
  53. edsl/exceptions/jobs.py +33 -33
  54. edsl/exceptions/language_models.py +63 -63
  55. edsl/exceptions/prompts.py +15 -15
  56. edsl/exceptions/questions.py +91 -109
  57. edsl/exceptions/results.py +29 -29
  58. edsl/exceptions/scenarios.py +22 -29
  59. edsl/exceptions/surveys.py +37 -37
  60. edsl/inference_services/AnthropicService.py +87 -84
  61. edsl/inference_services/AwsBedrock.py +120 -118
  62. edsl/inference_services/AzureAI.py +217 -215
  63. edsl/inference_services/DeepInfraService.py +18 -18
  64. edsl/inference_services/GoogleService.py +148 -139
  65. edsl/inference_services/GroqService.py +20 -20
  66. edsl/inference_services/InferenceServiceABC.py +147 -80
  67. edsl/inference_services/InferenceServicesCollection.py +97 -122
  68. edsl/inference_services/MistralAIService.py +123 -120
  69. edsl/inference_services/OllamaService.py +18 -18
  70. edsl/inference_services/OpenAIService.py +224 -221
  71. edsl/inference_services/PerplexityService.py +163 -160
  72. edsl/inference_services/TestService.py +89 -92
  73. edsl/inference_services/TogetherAIService.py +170 -170
  74. edsl/inference_services/models_available_cache.py +118 -118
  75. edsl/inference_services/rate_limits_cache.py +25 -25
  76. edsl/inference_services/registry.py +41 -41
  77. edsl/inference_services/write_available.py +10 -10
  78. edsl/jobs/Answers.py +56 -43
  79. edsl/jobs/Jobs.py +898 -757
  80. edsl/jobs/JobsChecks.py +147 -172
  81. edsl/jobs/JobsPrompts.py +268 -270
  82. edsl/jobs/JobsRemoteInferenceHandler.py +239 -287
  83. edsl/jobs/__init__.py +1 -1
  84. edsl/jobs/buckets/BucketCollection.py +63 -104
  85. edsl/jobs/buckets/ModelBuckets.py +65 -65
  86. edsl/jobs/buckets/TokenBucket.py +251 -283
  87. edsl/jobs/interviews/Interview.py +661 -358
  88. edsl/jobs/interviews/InterviewExceptionCollection.py +99 -99
  89. edsl/jobs/interviews/InterviewExceptionEntry.py +186 -186
  90. edsl/jobs/interviews/InterviewStatistic.py +63 -63
  91. edsl/jobs/interviews/InterviewStatisticsCollection.py +25 -25
  92. edsl/jobs/interviews/InterviewStatusDictionary.py +78 -78
  93. edsl/jobs/interviews/InterviewStatusLog.py +92 -92
  94. edsl/jobs/interviews/ReportErrors.py +66 -66
  95. edsl/jobs/interviews/interview_status_enum.py +9 -9
  96. edsl/jobs/runners/JobsRunnerAsyncio.py +466 -421
  97. edsl/jobs/runners/JobsRunnerStatus.py +330 -330
  98. edsl/jobs/tasks/QuestionTaskCreator.py +242 -244
  99. edsl/jobs/tasks/TaskCreators.py +64 -64
  100. edsl/jobs/tasks/TaskHistory.py +450 -449
  101. edsl/jobs/tasks/TaskStatusLog.py +23 -23
  102. edsl/jobs/tasks/task_status_enum.py +163 -161
  103. edsl/jobs/tokens/InterviewTokenUsage.py +27 -27
  104. edsl/jobs/tokens/TokenUsage.py +34 -34
  105. edsl/language_models/KeyLookup.py +30 -0
  106. edsl/language_models/LanguageModel.py +668 -571
  107. edsl/language_models/ModelList.py +155 -153
  108. edsl/language_models/RegisterLanguageModelsMeta.py +184 -184
  109. edsl/language_models/__init__.py +3 -2
  110. edsl/language_models/fake_openai_call.py +15 -15
  111. edsl/language_models/fake_openai_service.py +61 -61
  112. edsl/language_models/registry.py +190 -180
  113. edsl/language_models/repair.py +156 -156
  114. edsl/language_models/unused/ReplicateBase.py +83 -0
  115. edsl/language_models/utilities.py +64 -65
  116. edsl/notebooks/Notebook.py +258 -263
  117. edsl/notebooks/__init__.py +1 -1
  118. edsl/prompts/Prompt.py +362 -352
  119. edsl/prompts/__init__.py +2 -2
  120. edsl/questions/AnswerValidatorMixin.py +289 -334
  121. edsl/questions/QuestionBase.py +664 -509
  122. edsl/questions/QuestionBaseGenMixin.py +161 -165
  123. edsl/questions/QuestionBasePromptsMixin.py +217 -221
  124. edsl/questions/QuestionBudget.py +227 -227
  125. edsl/questions/QuestionCheckBox.py +359 -359
  126. edsl/questions/QuestionExtract.py +182 -182
  127. edsl/questions/QuestionFreeText.py +114 -113
  128. edsl/questions/QuestionFunctional.py +166 -166
  129. edsl/questions/QuestionList.py +231 -229
  130. edsl/questions/QuestionMultipleChoice.py +286 -330
  131. edsl/questions/QuestionNumerical.py +153 -151
  132. edsl/questions/QuestionRank.py +324 -314
  133. edsl/questions/Quick.py +41 -41
  134. edsl/questions/RegisterQuestionsMeta.py +71 -71
  135. edsl/questions/ResponseValidatorABC.py +174 -200
  136. edsl/questions/SimpleAskMixin.py +73 -74
  137. edsl/questions/__init__.py +26 -27
  138. edsl/questions/compose_questions.py +98 -98
  139. edsl/questions/decorators.py +21 -21
  140. edsl/questions/derived/QuestionLikertFive.py +76 -76
  141. edsl/questions/derived/QuestionLinearScale.py +87 -90
  142. edsl/questions/derived/QuestionTopK.py +93 -93
  143. edsl/questions/derived/QuestionYesNo.py +82 -82
  144. edsl/questions/descriptors.py +413 -427
  145. edsl/questions/prompt_templates/question_budget.jinja +13 -13
  146. edsl/questions/prompt_templates/question_checkbox.jinja +32 -32
  147. edsl/questions/prompt_templates/question_extract.jinja +11 -11
  148. edsl/questions/prompt_templates/question_free_text.jinja +3 -3
  149. edsl/questions/prompt_templates/question_linear_scale.jinja +11 -11
  150. edsl/questions/prompt_templates/question_list.jinja +17 -17
  151. edsl/questions/prompt_templates/question_multiple_choice.jinja +33 -33
  152. edsl/questions/prompt_templates/question_numerical.jinja +36 -36
  153. edsl/questions/question_registry.py +177 -177
  154. edsl/questions/settings.py +12 -12
  155. edsl/questions/templates/budget/answering_instructions.jinja +7 -7
  156. edsl/questions/templates/budget/question_presentation.jinja +7 -7
  157. edsl/questions/templates/checkbox/answering_instructions.jinja +10 -10
  158. edsl/questions/templates/checkbox/question_presentation.jinja +22 -22
  159. edsl/questions/templates/extract/answering_instructions.jinja +7 -7
  160. edsl/questions/templates/likert_five/answering_instructions.jinja +10 -10
  161. edsl/questions/templates/likert_five/question_presentation.jinja +11 -11
  162. edsl/questions/templates/linear_scale/answering_instructions.jinja +5 -5
  163. edsl/questions/templates/linear_scale/question_presentation.jinja +5 -5
  164. edsl/questions/templates/list/answering_instructions.jinja +3 -3
  165. edsl/questions/templates/list/question_presentation.jinja +5 -5
  166. edsl/questions/templates/multiple_choice/answering_instructions.jinja +9 -9
  167. edsl/questions/templates/multiple_choice/question_presentation.jinja +11 -11
  168. edsl/questions/templates/numerical/answering_instructions.jinja +6 -6
  169. edsl/questions/templates/numerical/question_presentation.jinja +6 -6
  170. edsl/questions/templates/rank/answering_instructions.jinja +11 -11
  171. edsl/questions/templates/rank/question_presentation.jinja +15 -15
  172. edsl/questions/templates/top_k/answering_instructions.jinja +8 -8
  173. edsl/questions/templates/top_k/question_presentation.jinja +22 -22
  174. edsl/questions/templates/yes_no/answering_instructions.jinja +6 -6
  175. edsl/questions/templates/yes_no/question_presentation.jinja +11 -11
  176. edsl/results/CSSParameterizer.py +108 -108
  177. edsl/results/Dataset.py +424 -587
  178. edsl/results/DatasetExportMixin.py +731 -653
  179. edsl/results/DatasetTree.py +275 -295
  180. edsl/results/Result.py +465 -451
  181. edsl/results/Results.py +1165 -1172
  182. edsl/results/ResultsDBMixin.py +238 -0
  183. edsl/results/ResultsExportMixin.py +43 -45
  184. edsl/results/ResultsFetchMixin.py +33 -33
  185. edsl/results/ResultsGGMixin.py +121 -121
  186. edsl/results/ResultsToolsMixin.py +98 -98
  187. edsl/results/Selector.py +135 -145
  188. edsl/results/TableDisplay.py +198 -125
  189. edsl/results/__init__.py +2 -2
  190. edsl/results/table_display.css +77 -77
  191. edsl/results/tree_explore.py +115 -115
  192. edsl/scenarios/FileStore.py +632 -511
  193. edsl/scenarios/Scenario.py +601 -498
  194. edsl/scenarios/ScenarioHtmlMixin.py +64 -65
  195. edsl/scenarios/ScenarioJoin.py +127 -131
  196. edsl/scenarios/ScenarioList.py +1287 -1430
  197. edsl/scenarios/ScenarioListExportMixin.py +52 -45
  198. edsl/scenarios/ScenarioListPdfMixin.py +261 -239
  199. edsl/scenarios/__init__.py +4 -3
  200. edsl/shared.py +1 -1
  201. edsl/study/ObjectEntry.py +173 -173
  202. edsl/study/ProofOfWork.py +113 -113
  203. edsl/study/SnapShot.py +80 -80
  204. edsl/study/Study.py +528 -521
  205. edsl/study/__init__.py +4 -4
  206. edsl/surveys/DAG.py +148 -148
  207. edsl/surveys/Memory.py +31 -31
  208. edsl/surveys/MemoryPlan.py +244 -244
  209. edsl/surveys/Rule.py +326 -327
  210. edsl/surveys/RuleCollection.py +387 -385
  211. edsl/surveys/Survey.py +1801 -1229
  212. edsl/surveys/SurveyCSS.py +261 -273
  213. edsl/surveys/SurveyExportMixin.py +259 -259
  214. edsl/surveys/{SurveyFlowVisualization.py → SurveyFlowVisualizationMixin.py} +179 -181
  215. edsl/surveys/SurveyQualtricsImport.py +284 -284
  216. edsl/surveys/__init__.py +3 -5
  217. edsl/surveys/base.py +53 -53
  218. edsl/surveys/descriptors.py +56 -60
  219. edsl/surveys/instructions/ChangeInstruction.py +49 -48
  220. edsl/surveys/instructions/Instruction.py +65 -56
  221. edsl/surveys/instructions/InstructionCollection.py +77 -82
  222. edsl/templates/error_reporting/base.html +23 -23
  223. edsl/templates/error_reporting/exceptions_by_model.html +34 -34
  224. edsl/templates/error_reporting/exceptions_by_question_name.html +16 -16
  225. edsl/templates/error_reporting/exceptions_by_type.html +16 -16
  226. edsl/templates/error_reporting/interview_details.html +115 -115
  227. edsl/templates/error_reporting/interviews.html +19 -19
  228. edsl/templates/error_reporting/overview.html +4 -4
  229. edsl/templates/error_reporting/performance_plot.html +1 -1
  230. edsl/templates/error_reporting/report.css +73 -73
  231. edsl/templates/error_reporting/report.html +117 -117
  232. edsl/templates/error_reporting/report.js +25 -25
  233. edsl/tools/__init__.py +1 -1
  234. edsl/tools/clusters.py +192 -192
  235. edsl/tools/embeddings.py +27 -27
  236. edsl/tools/embeddings_plotting.py +118 -118
  237. edsl/tools/plotting.py +112 -112
  238. edsl/tools/summarize.py +18 -18
  239. edsl/utilities/SystemInfo.py +28 -28
  240. edsl/utilities/__init__.py +22 -22
  241. edsl/utilities/ast_utilities.py +25 -25
  242. edsl/utilities/data/Registry.py +6 -6
  243. edsl/utilities/data/__init__.py +1 -1
  244. edsl/utilities/data/scooter_results.json +1 -1
  245. edsl/utilities/decorators.py +77 -77
  246. edsl/utilities/gcp_bucket/cloud_storage.py +96 -96
  247. edsl/utilities/interface.py +627 -627
  248. edsl/utilities/naming_utilities.py +263 -263
  249. edsl/utilities/repair_functions.py +28 -28
  250. edsl/utilities/restricted_python.py +70 -70
  251. edsl/utilities/utilities.py +424 -436
  252. {edsl-0.1.39.dev2.dist-info → edsl-0.1.39.dev3.dist-info}/LICENSE +21 -21
  253. {edsl-0.1.39.dev2.dist-info → edsl-0.1.39.dev3.dist-info}/METADATA +10 -12
  254. edsl-0.1.39.dev3.dist-info/RECORD +277 -0
  255. edsl/agents/QuestionInstructionPromptBuilder.py +0 -128
  256. edsl/agents/QuestionOptionProcessor.py +0 -172
  257. edsl/agents/QuestionTemplateReplacementsBuilder.py +0 -137
  258. edsl/coop/CoopFunctionsMixin.py +0 -15
  259. edsl/coop/ExpectedParrotKeyHandler.py +0 -125
  260. edsl/exceptions/inference_services.py +0 -5
  261. edsl/inference_services/AvailableModelCacheHandler.py +0 -184
  262. edsl/inference_services/AvailableModelFetcher.py +0 -209
  263. edsl/inference_services/ServiceAvailability.py +0 -135
  264. edsl/inference_services/data_structures.py +0 -62
  265. edsl/jobs/AnswerQuestionFunctionConstructor.py +0 -188
  266. edsl/jobs/FetchInvigilator.py +0 -40
  267. edsl/jobs/InterviewTaskManager.py +0 -98
  268. edsl/jobs/InterviewsConstructor.py +0 -48
  269. edsl/jobs/JobsComponentConstructor.py +0 -189
  270. edsl/jobs/JobsRemoteInferenceLogger.py +0 -239
  271. edsl/jobs/RequestTokenEstimator.py +0 -30
  272. edsl/jobs/buckets/TokenBucketAPI.py +0 -211
  273. edsl/jobs/buckets/TokenBucketClient.py +0 -191
  274. edsl/jobs/decorators.py +0 -35
  275. edsl/jobs/jobs_status_enums.py +0 -9
  276. edsl/jobs/loggers/HTMLTableJobLogger.py +0 -304
  277. edsl/language_models/ComputeCost.py +0 -63
  278. edsl/language_models/PriceManager.py +0 -127
  279. edsl/language_models/RawResponseHandler.py +0 -106
  280. edsl/language_models/ServiceDataSources.py +0 -0
  281. edsl/language_models/key_management/KeyLookup.py +0 -63
  282. edsl/language_models/key_management/KeyLookupBuilder.py +0 -273
  283. edsl/language_models/key_management/KeyLookupCollection.py +0 -38
  284. edsl/language_models/key_management/__init__.py +0 -0
  285. edsl/language_models/key_management/models.py +0 -131
  286. edsl/notebooks/NotebookToLaTeX.py +0 -142
  287. edsl/questions/ExceptionExplainer.py +0 -77
  288. edsl/questions/HTMLQuestion.py +0 -103
  289. edsl/questions/LoopProcessor.py +0 -149
  290. edsl/questions/QuestionMatrix.py +0 -265
  291. edsl/questions/ResponseValidatorFactory.py +0 -28
  292. edsl/questions/templates/matrix/__init__.py +0 -1
  293. edsl/questions/templates/matrix/answering_instructions.jinja +0 -5
  294. edsl/questions/templates/matrix/question_presentation.jinja +0 -20
  295. edsl/results/MarkdownToDocx.py +0 -122
  296. edsl/results/MarkdownToPDF.py +0 -111
  297. edsl/results/TextEditor.py +0 -50
  298. edsl/results/smart_objects.py +0 -96
  299. edsl/results/table_data_class.py +0 -12
  300. edsl/results/table_renderers.py +0 -118
  301. edsl/scenarios/ConstructDownloadLink.py +0 -109
  302. edsl/scenarios/DirectoryScanner.py +0 -96
  303. edsl/scenarios/DocumentChunker.py +0 -102
  304. edsl/scenarios/DocxScenario.py +0 -16
  305. edsl/scenarios/PdfExtractor.py +0 -40
  306. edsl/scenarios/ScenarioSelector.py +0 -156
  307. edsl/scenarios/file_methods.py +0 -85
  308. edsl/scenarios/handlers/__init__.py +0 -13
  309. edsl/scenarios/handlers/csv.py +0 -38
  310. edsl/scenarios/handlers/docx.py +0 -76
  311. edsl/scenarios/handlers/html.py +0 -37
  312. edsl/scenarios/handlers/json.py +0 -111
  313. edsl/scenarios/handlers/latex.py +0 -5
  314. edsl/scenarios/handlers/md.py +0 -51
  315. edsl/scenarios/handlers/pdf.py +0 -68
  316. edsl/scenarios/handlers/png.py +0 -39
  317. edsl/scenarios/handlers/pptx.py +0 -105
  318. edsl/scenarios/handlers/py.py +0 -294
  319. edsl/scenarios/handlers/sql.py +0 -313
  320. edsl/scenarios/handlers/sqlite.py +0 -149
  321. edsl/scenarios/handlers/txt.py +0 -33
  322. edsl/surveys/ConstructDAG.py +0 -92
  323. edsl/surveys/EditSurvey.py +0 -221
  324. edsl/surveys/InstructionHandler.py +0 -100
  325. edsl/surveys/MemoryManagement.py +0 -72
  326. edsl/surveys/RuleManager.py +0 -172
  327. edsl/surveys/Simulator.py +0 -75
  328. edsl/surveys/SurveyToApp.py +0 -141
  329. edsl/utilities/PrettyList.py +0 -56
  330. edsl/utilities/is_notebook.py +0 -18
  331. edsl/utilities/is_valid_variable_name.py +0 -11
  332. edsl/utilities/remove_edsl_version.py +0 -24
  333. edsl-0.1.39.dev2.dist-info/RECORD +0 -352
  334. {edsl-0.1.39.dev2.dist-info → edsl-0.1.39.dev3.dist-info}/WHEEL +0 -0
@@ -1,427 +1,413 @@
1
- """This module contains the descriptors used to validate the attributes of the question classes."""
2
-
3
- from abc import ABC, abstractmethod
4
- import re
5
- from typing import Any, Callable, List, Optional
6
- from edsl.exceptions.questions import (
7
- QuestionCreationValidationError,
8
- QuestionAnswerValidationError,
9
- )
10
- from edsl.questions.settings import Settings
11
-
12
-
13
- ################################
14
- # Helper functions
15
- ################################
16
-
17
-
18
- def contains_single_braced_substring(s: str) -> bool:
19
- """Check if the string contains a substring in single braces."""
20
- pattern = r"(?<!\{)\{[^{}]+\}(?!\})"
21
- match = re.search(pattern, s)
22
- return bool(match)
23
-
24
-
25
- def is_number(value: Any) -> bool:
26
- """Check if an object is a number."""
27
- return isinstance(value, int) or isinstance(value, float)
28
-
29
-
30
- def is_number_or_none(value: Any) -> bool:
31
- """Check if an object is a number or None."""
32
- return value is None or is_number(value)
33
-
34
-
35
- ################################
36
- # Descriptor ABC
37
- ################################
38
-
39
-
40
- class BaseDescriptor(ABC):
41
- """ABC for something."""
42
-
43
- @abstractmethod
44
- def validate(self, value: Any) -> None:
45
- """Validate the value. If it is invalid, raises an exception. If it is valid, does nothing."""
46
- pass
47
-
48
- def __get__(self, instance, owner):
49
- """Get the value of the attribute."""
50
- if self.name not in instance.__dict__:
51
- return {}
52
- return instance.__dict__[self.name]
53
-
54
- def __set__(self, instance, value: Any) -> None:
55
- """Set the value of the attribute."""
56
- new_value = self.validate(value, instance)
57
-
58
- if new_value is not None:
59
- instance.__dict__[self.name] = new_value
60
- else:
61
- instance.__dict__[self.name] = value
62
-
63
- def __set_name__(self, owner, name: str) -> None:
64
- """Set the name of the attribute."""
65
- self.name = "_" + name
66
-
67
-
68
- ################################
69
- # General descriptors
70
- ################################
71
-
72
-
73
- class FunctionDescriptor(BaseDescriptor):
74
- """Validate that a value is a function."""
75
-
76
- def validate(self, value: Any, instance) -> Callable:
77
- """Validate the value is a function, and if so, returns it."""
78
- if not callable(value):
79
- raise QuestionCreationValidationError(
80
- f"Expected a function (got {value}).)"
81
- )
82
- return value
83
-
84
-
85
- class IntegerDescriptor(BaseDescriptor):
86
- """
87
- Validate that a value is an integer.
88
-
89
- - `none_allowed` is whether None is allowed as a value.
90
- """
91
-
92
- def __init__(self, none_allowed: bool = False):
93
- """Initialize the descriptor."""
94
- self.none_allowed = none_allowed
95
-
96
- def validate(self, value, instance):
97
- """Validate the value is an integer."""
98
- if self.none_allowed:
99
- if not (isinstance(value, int) or value is None):
100
- raise QuestionAnswerValidationError(
101
- f"Expected an integer or None (got {value})."
102
- )
103
- else:
104
- if not isinstance(value, int):
105
- raise QuestionAnswerValidationError(
106
- f"Expected an integer (got {value})."
107
- )
108
-
109
-
110
- class IntegerOrNoneDescriptor(BaseDescriptor):
111
- """Validate that a value is an integer or None."""
112
-
113
- def validate(self, value, instance):
114
- """Validate the value is an integer or None."""
115
- if not (isinstance(value, int) or value is None):
116
- raise QuestionCreationValidationError(
117
- f"Expected an integer or None (got {value})."
118
- )
119
-
120
-
121
- class NumericalOrNoneDescriptor(BaseDescriptor):
122
- """Validate that a value is a number or None."""
123
-
124
- def validate(self, value, instance):
125
- """Validate the value is a number or None."""
126
- if not is_number_or_none(value):
127
- raise QuestionAnswerValidationError(
128
- f"Expected a number or None (got {value})."
129
- )
130
-
131
-
132
- ################################
133
- # Attribute-specific descriptors
134
- ################################
135
-
136
-
137
- class AnswerTemplateDescriptor(BaseDescriptor):
138
- """Validate that the answer template is a dictionary with string keys and string values."""
139
-
140
- def validate(self, value: Any, instance) -> None:
141
- """Validate the answer template."""
142
- if not isinstance(value, dict):
143
- raise QuestionCreationValidationError(
144
- f"`answer_template` must be a dictionary (got {value}).)"
145
- )
146
- if not all(isinstance(x, str) for x in value.keys()):
147
- raise QuestionCreationValidationError(
148
- f"`answer_template` keys must be strings (got {value})."
149
- )
150
-
151
-
152
- class InstructionsDescriptor(BaseDescriptor):
153
- """Validate that the `instructions` attribute is a string."""
154
-
155
- def validate(self, value, instance):
156
- """Validate the value is a string."""
157
- # if not isinstance(value, str):
158
- # raise QuestionCreationValidationError(
159
- # f"Question `instructions` must be a string (got {value})."
160
- # )
161
- pass
162
-
163
-
164
- class NumSelectionsDescriptor(BaseDescriptor):
165
- """Validate that `num_selections` is an integer, is less than the number of options, and is positive."""
166
-
167
- def validate(self, value, instance):
168
- """Validate the value is an integer, is less than the number of options, and is positive."""
169
- if not (isinstance(value, int)):
170
- raise QuestionCreationValidationError(
171
- f"`num_selections` must be an integer (got {value})."
172
- )
173
- if value > len(instance.question_options):
174
- raise QuestionAnswerValidationError(
175
- f"`num_selections` must be less than the number of options (got {value})."
176
- )
177
- if value < 1:
178
- raise QuestionAnswerValidationError(
179
- f"`num_selections` must a positive integer (got {value})."
180
- )
181
-
182
-
183
- class OptionLabelDescriptor(BaseDescriptor):
184
- """Validate that the `option_label` attribute is a string.
185
-
186
- >>> class TestQuestion:
187
- ... option_label = OptionLabelDescriptor()
188
- ... def __init__(self, option_label: str):
189
- ... self.option_label = option_label
190
-
191
- >>> _ = TestQuestion("{{Option}}")
192
-
193
- """
194
-
195
- def validate(self, value, instance):
196
- """Validate the value is a string."""
197
- if isinstance(value, str):
198
- if "{{" in value and "}}" in value:
199
- # they're trying to use a dynamic question name - let's let this play out
200
- return None
201
-
202
- key_values = [int(v) for v in value.keys()]
203
-
204
- if value and (key_values := [float(v) for v in value.keys()]) != []:
205
- if min(key_values) != min(instance.question_options):
206
- raise QuestionCreationValidationError(
207
- f"First option needs a label (got {value})"
208
- )
209
- if max(key_values) != max(instance.question_options):
210
- raise QuestionCreationValidationError(
211
- f"Last option needs a label (got {value})"
212
- )
213
- if not all(isinstance(x, str) for x in value.values()):
214
- raise QuestionCreationValidationError(
215
- "Option labels must be strings (got {value})."
216
- )
217
- for key in key_values:
218
- if key not in instance.question_options:
219
- raise QuestionCreationValidationError(
220
- f"Option label key ({key}) is not in question options ({instance.question_options})."
221
- )
222
-
223
- if len(value.values()) != len(set(value.values())):
224
- raise QuestionCreationValidationError(
225
- f"Option labels must be unique (got {value})."
226
- )
227
-
228
-
229
- class QuestionNameDescriptor(BaseDescriptor):
230
- """Validate that the `question_name` attribute is a valid variable name."""
231
-
232
- def validate(self, value, instance):
233
- """Validate the value is a valid variable name."""
234
- from edsl.utilities.utilities import is_valid_variable_name
235
-
236
- if "{{" in value and "}}" in value:
237
- # they're trying to use a dynamic question name - let's let this play out
238
- return None
239
-
240
- if value.endswith("_comment") or value.endswith("_generated_tokens"):
241
- raise QuestionCreationValidationError(
242
- f"`question_name` cannot end with '_comment' or '_generated_tokens - (got {value})."
243
- )
244
-
245
- if not is_valid_variable_name(value):
246
- raise QuestionCreationValidationError(
247
- f"`question_name` is not a valid variable name (got {value})."
248
- )
249
-
250
-
251
- class QuestionOptionsDescriptor(BaseDescriptor):
252
- """Validate that `question_options` is a list, does not exceed the min/max lengths, and has unique items."""
253
-
254
- @classmethod
255
- def example(cls):
256
- class TestQuestion:
257
- question_options = QuestionOptionsDescriptor()
258
-
259
- def __init__(self, question_options: List[str]):
260
- self.question_options = question_options
261
-
262
- return TestQuestion
263
-
264
- def __init__(
265
- self,
266
- num_choices: int = None,
267
- linear_scale: bool = False,
268
- q_budget: bool = False,
269
- ):
270
- """Initialize the descriptor."""
271
- self.num_choices = num_choices
272
- self.linear_scale = linear_scale
273
- self.q_budget = q_budget
274
-
275
- def validate(self, value: Any, instance) -> None:
276
- """Validate the question options.
277
-
278
- >>> q_class = QuestionOptionsDescriptor.example()
279
- >>> _ = q_class(["a", "b", "c"])
280
- >>> _ = q_class(["a", "b", "c", "d", "d"])
281
- Traceback (most recent call last):
282
- ...
283
- edsl.exceptions.questions.QuestionCreationValidationError: Question options must be unique (got ['a', 'b', 'c', 'd', 'd']).
284
-
285
- We allow dynamic question options, which are strings of the form '{{ question_options }}'.
286
-
287
- >>> _ = q_class("{{dynamic_options}}")
288
- >>> _ = q_class("dynamic_options")
289
- Traceback (most recent call last):
290
- ...
291
- edsl.exceptions.questions.QuestionCreationValidationError: ...
292
- """
293
- if isinstance(value, str):
294
- # Check if the string is a dynamic question option
295
- if "{{" in value and "}}" in value:
296
- return None
297
- else:
298
- raise QuestionCreationValidationError(
299
- f"Dynamic question options must have jinja2 braces - instead received: {value}."
300
- )
301
- if not isinstance(value, list):
302
- raise QuestionCreationValidationError(
303
- f"Question options must be a list (got {value})."
304
- )
305
- if len(value) > Settings.MAX_NUM_OPTIONS:
306
- raise QuestionCreationValidationError(
307
- f"Too many question options (got {value})."
308
- )
309
- if len(value) < Settings.MIN_NUM_OPTIONS:
310
- raise QuestionCreationValidationError(
311
- f"Too few question options (got {value})."
312
- )
313
- # handle the case when question_options is a list of lists (a list of list can be converted to set)
314
- tmp_value = [str(x) for x in value]
315
- if len(tmp_value) != len(set(tmp_value)):
316
- raise QuestionCreationValidationError(
317
- f"Question options must be unique (got {value})."
318
- )
319
- if not self.linear_scale:
320
- if not self.q_budget:
321
- pass
322
- # if not (
323
- # value
324
- # and all(type(x) == type(value[0]) for x in value)
325
- # and isinstance(value[0], (str, list, int, float))
326
- # ):
327
- # raise QuestionCreationValidationError(
328
- # f"Question options must be all same type (got {value}).)"
329
- # )
330
- else:
331
- if not all(isinstance(x, (str)) for x in value):
332
- raise QuestionCreationValidationError(
333
- f"Question options must be strings (got {value}).)"
334
- )
335
- if not all(
336
- [
337
- type(option) != str
338
- or (len(option) >= 1 and len(option) < Settings.MAX_OPTION_LENGTH)
339
- for option in value
340
- ]
341
- ):
342
- raise QuestionCreationValidationError(
343
- f"All question options must be at least 1 character long but less than {Settings.MAX_OPTION_LENGTH} characters long (got {value})."
344
- )
345
-
346
- if hasattr(instance, "min_selections") and instance.min_selections != None:
347
- if instance.min_selections > len(value):
348
- raise QuestionCreationValidationError(
349
- f"You asked for at least {instance.min_selections} selections, but provided fewer options (got {value})."
350
- )
351
- if hasattr(instance, "max_selections") and instance.max_selections != None:
352
- if instance.max_selections > len(value):
353
- raise QuestionCreationValidationError(
354
- f"You asked for at most {instance.max_selections} selections, but provided fewer options (got {value})."
355
- )
356
- if self.num_choices is not None:
357
- if len(value) != self.num_choices:
358
- raise QuestionCreationValidationError(
359
- f"You asked for {self.num_choices} selections, but provided {len(value)} options."
360
- )
361
- if self.linear_scale:
362
- if sorted(value) != list(range(min(value), max(value) + 1)):
363
- raise QuestionCreationValidationError(
364
- f"LinearScale.question_options must be a list of successive integers, e.g. [1, 2, 3] (got {value})."
365
- )
366
-
367
-
368
- class QuestionTextDescriptor(BaseDescriptor):
369
- """Validate that the `question_text` attribute is a string.
370
-
371
-
372
- >>> class TestQuestion:
373
- ... question_text = QuestionTextDescriptor()
374
- ... def __init__(self, question_text: str):
375
- ... self.question_text = question_text
376
-
377
- >>> _ = TestQuestion("What is the capital of France?")
378
- >>> _ = TestQuestion("What is the capital of France? {{variable}}")
379
- >>> _ = TestQuestion("What is the capital of France? {{variable name}}")
380
- Traceback (most recent call last):
381
- ...
382
- edsl.exceptions.questions.QuestionCreationValidationError: Question text contains an invalid identifier: 'variable name'
383
- """
384
-
385
- def validate(self, value, instance):
386
- """Validate the value is a string."""
387
- # if len(value) > Settings.MAX_QUESTION_LENGTH:
388
- # raise Exception("Question is too long!")
389
- if len(value) < 1:
390
- raise Exception("Question is too short!")
391
- if not isinstance(value, str):
392
- raise Exception("Question must be a string!")
393
- if contains_single_braced_substring(value):
394
- import warnings
395
-
396
- # # warnings.warn(
397
- # # f"WARNING: Question text contains a single-braced substring: If you intended to parameterize the question with a Scenario this should be changed to a double-braced substring, e.g. {{variable}}.\nSee details on constructing Scenarios in the docs: https://docs.expectedparrot.com/en/latest/scenarios.html",
398
- # # UserWarning,
399
- # # )
400
- warnings.warn(
401
- "WARNING: Question text contains a single-braced substring. "
402
- "If you intended to parameterize the question with a Scenario, this will "
403
- "be changed to a double-braced substring, e.g. {{variable}}.\n"
404
- "See details on constructing Scenarios in the docs: "
405
- "https://docs.expectedparrot.com/en/latest/scenarios.html",
406
- UserWarning,
407
- )
408
- # Automatically replace single braces with double braces
409
- # This is here because if the user is using an f-string, the double brace will get converted to a single brace.
410
- # This undoes that.
411
- value = re.sub(r"\{([^\{\}]+)\}", r"{{\1}}", value)
412
- return value
413
-
414
- # iterate through all doubles braces and check if they are valid python identifiers
415
- for match in re.finditer(r"\{\{([^\{\}]+)\}\}", value):
416
- if " " in match.group(1).strip():
417
- raise QuestionCreationValidationError(
418
- f"Question text contains an invalid identifier: '{match.group(1)}'"
419
- )
420
-
421
- return None
422
-
423
-
424
- if __name__ == "__main__":
425
- import doctest
426
-
427
- doctest.testmod(optionflags=doctest.ELLIPSIS)
1
+ """This module contains the descriptors used to validate the attributes of the question classes."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ import re
5
+ from typing import Any, Callable, List, Optional
6
+ from edsl.exceptions import (
7
+ QuestionCreationValidationError,
8
+ QuestionAnswerValidationError,
9
+ )
10
+ from edsl.questions.settings import Settings
11
+
12
+
13
+ ################################
14
+ # Helper functions
15
+ ################################
16
+
17
+
18
+ def contains_single_braced_substring(s: str) -> bool:
19
+ """Check if the string contains a substring in single braces."""
20
+ pattern = r"(?<!\{)\{[^{}]+\}(?!\})"
21
+ match = re.search(pattern, s)
22
+ return bool(match)
23
+
24
+
25
+ def is_number(value: Any) -> bool:
26
+ """Check if an object is a number."""
27
+ return isinstance(value, int) or isinstance(value, float)
28
+
29
+
30
+ def is_number_or_none(value: Any) -> bool:
31
+ """Check if an object is a number or None."""
32
+ return value is None or is_number(value)
33
+
34
+
35
+ ################################
36
+ # Descriptor ABC
37
+ ################################
38
+
39
+
40
+ class BaseDescriptor(ABC):
41
+ """ABC for something."""
42
+
43
+ @abstractmethod
44
+ def validate(self, value: Any) -> None:
45
+ """Validate the value. If it is invalid, raises an exception. If it is valid, does nothing."""
46
+ pass
47
+
48
+ def __get__(self, instance, owner):
49
+ """Get the value of the attribute."""
50
+ if self.name not in instance.__dict__:
51
+ return {}
52
+ return instance.__dict__[self.name]
53
+
54
+ def __set__(self, instance, value: Any) -> None:
55
+ """Set the value of the attribute."""
56
+ new_value = self.validate(value, instance)
57
+
58
+ if new_value is not None:
59
+ instance.__dict__[self.name] = new_value
60
+ else:
61
+ instance.__dict__[self.name] = value
62
+
63
+ def __set_name__(self, owner, name: str) -> None:
64
+ """Set the name of the attribute."""
65
+ self.name = "_" + name
66
+
67
+
68
+ ################################
69
+ # General descriptors
70
+ ################################
71
+
72
+
73
+ class FunctionDescriptor(BaseDescriptor):
74
+ """Validate that a value is a function."""
75
+
76
+ def validate(self, value: Any, instance) -> Callable:
77
+ """Validate the value is a function, and if so, returns it."""
78
+ if not callable(value):
79
+ raise QuestionCreationValidationError(
80
+ f"Expected a function (got {value}).)"
81
+ )
82
+ return value
83
+
84
+
85
+ class IntegerDescriptor(BaseDescriptor):
86
+ """
87
+ Validate that a value is an integer.
88
+
89
+ - `none_allowed` is whether None is allowed as a value.
90
+ """
91
+
92
+ def __init__(self, none_allowed: bool = False):
93
+ """Initialize the descriptor."""
94
+ self.none_allowed = none_allowed
95
+
96
+ def validate(self, value, instance):
97
+ """Validate the value is an integer."""
98
+ if self.none_allowed:
99
+ if not (isinstance(value, int) or value is None):
100
+ raise QuestionAnswerValidationError(
101
+ f"Expected an integer or None (got {value})."
102
+ )
103
+ else:
104
+ if not isinstance(value, int):
105
+ raise QuestionAnswerValidationError(
106
+ f"Expected an integer (got {value})."
107
+ )
108
+
109
+
110
+ class IntegerOrNoneDescriptor(BaseDescriptor):
111
+ """Validate that a value is an integer or None."""
112
+
113
+ def validate(self, value, instance):
114
+ """Validate the value is an integer or None."""
115
+ if not (isinstance(value, int) or value is None):
116
+ raise QuestionCreationValidationError(
117
+ f"Expected an integer or None (got {value})."
118
+ )
119
+
120
+
121
+ class NumericalOrNoneDescriptor(BaseDescriptor):
122
+ """Validate that a value is a number or None."""
123
+
124
+ def validate(self, value, instance):
125
+ """Validate the value is a number or None."""
126
+ if not is_number_or_none(value):
127
+ raise QuestionAnswerValidationError(
128
+ f"Expected a number or None (got {value})."
129
+ )
130
+
131
+
132
+ ################################
133
+ # Attribute-specific descriptors
134
+ ################################
135
+
136
+
137
+ class AnswerTemplateDescriptor(BaseDescriptor):
138
+ """Validate that the answer template is a dictionary with string keys and string values."""
139
+
140
+ def validate(self, value: Any, instance) -> None:
141
+ """Validate the answer template."""
142
+ if not isinstance(value, dict):
143
+ raise QuestionCreationValidationError(
144
+ f"`answer_template` must be a dictionary (got {value}).)"
145
+ )
146
+ if not all(isinstance(x, str) for x in value.keys()):
147
+ raise QuestionCreationValidationError(
148
+ f"`answer_template` keys must be strings (got {value})."
149
+ )
150
+
151
+
152
+ class InstructionsDescriptor(BaseDescriptor):
153
+ """Validate that the `instructions` attribute is a string."""
154
+
155
+ def validate(self, value, instance):
156
+ """Validate the value is a string."""
157
+ # if not isinstance(value, str):
158
+ # raise QuestionCreationValidationError(
159
+ # f"Question `instructions` must be a string (got {value})."
160
+ # )
161
+ pass
162
+
163
+
164
+ class NumSelectionsDescriptor(BaseDescriptor):
165
+ """Validate that `num_selections` is an integer, is less than the number of options, and is positive."""
166
+
167
+ def validate(self, value, instance):
168
+ """Validate the value is an integer, is less than the number of options, and is positive."""
169
+ if not (isinstance(value, int)):
170
+ raise QuestionCreationValidationError(
171
+ f"`num_selections` must be an integer (got {value})."
172
+ )
173
+ if value > len(instance.question_options):
174
+ raise QuestionAnswerValidationError(
175
+ f"`num_selections` must be less than the number of options (got {value})."
176
+ )
177
+ if value < 1:
178
+ raise QuestionAnswerValidationError(
179
+ f"`num_selections` must a positive integer (got {value})."
180
+ )
181
+
182
+
183
+ class OptionLabelDescriptor(BaseDescriptor):
184
+ """Validate that the `option_label` attribute is a string."""
185
+
186
+ def validate(self, value, instance):
187
+ """Validate the value is a string."""
188
+ # key_values = [int(v) for v in value.keys()]
189
+
190
+ if value and (key_values := [float(v) for v in value.keys()]) != []:
191
+ if min(key_values) != min(instance.question_options):
192
+ raise QuestionCreationValidationError(
193
+ f"First option needs a label (got {value})"
194
+ )
195
+ if max(key_values) != max(instance.question_options):
196
+ raise QuestionCreationValidationError(
197
+ f"Last option needs a label (got {value})"
198
+ )
199
+ if not all(isinstance(x, str) for x in value.values()):
200
+ raise QuestionCreationValidationError(
201
+ "Option labels must be strings (got {value})."
202
+ )
203
+ for key in key_values:
204
+ if key not in instance.question_options:
205
+ raise QuestionCreationValidationError(
206
+ f"Option label key ({key}) is not in question options ({instance.question_options})."
207
+ )
208
+
209
+ if len(value.values()) != len(set(value.values())):
210
+ raise QuestionCreationValidationError(
211
+ f"Option labels must be unique (got {value})."
212
+ )
213
+
214
+
215
+ class QuestionNameDescriptor(BaseDescriptor):
216
+ """Validate that the `question_name` attribute is a valid variable name."""
217
+
218
+ def validate(self, value, instance):
219
+ """Validate the value is a valid variable name."""
220
+ from edsl.utilities.utilities import is_valid_variable_name
221
+
222
+ if "{{" in value and "}}" in value:
223
+ # they're trying to use a dynamic question name - let's let this play out
224
+ return None
225
+
226
+ if value.endswith("_comment") or value.endswith("_generated_tokens"):
227
+ raise QuestionCreationValidationError(
228
+ f"`question_name` cannot end with '_comment' or '_generated_tokens - (got {value})."
229
+ )
230
+
231
+ if not is_valid_variable_name(value):
232
+ raise QuestionCreationValidationError(
233
+ f"`question_name` is not a valid variable name (got {value})."
234
+ )
235
+
236
+
237
+ class QuestionOptionsDescriptor(BaseDescriptor):
238
+ """Validate that `question_options` is a list, does not exceed the min/max lengths, and has unique items."""
239
+
240
+ @classmethod
241
+ def example(cls):
242
+ class TestQuestion:
243
+ question_options = QuestionOptionsDescriptor()
244
+
245
+ def __init__(self, question_options: List[str]):
246
+ self.question_options = question_options
247
+
248
+ return TestQuestion
249
+
250
+ def __init__(
251
+ self,
252
+ num_choices: int = None,
253
+ linear_scale: bool = False,
254
+ q_budget: bool = False,
255
+ ):
256
+ """Initialize the descriptor."""
257
+ self.num_choices = num_choices
258
+ self.linear_scale = linear_scale
259
+ self.q_budget = q_budget
260
+
261
+ def validate(self, value: Any, instance) -> None:
262
+ """Validate the question options.
263
+
264
+ >>> q_class = QuestionOptionsDescriptor.example()
265
+ >>> _ = q_class(["a", "b", "c"])
266
+ >>> _ = q_class(["a", "b", "c", "d", "d"])
267
+ Traceback (most recent call last):
268
+ ...
269
+ edsl.exceptions.questions.QuestionCreationValidationError: Question options must be unique (got ['a', 'b', 'c', 'd', 'd']).
270
+
271
+ We allow dynamic question options, which are strings of the form '{{ question_options }}'.
272
+
273
+ >>> _ = q_class("{{dynamic_options}}")
274
+ >>> _ = q_class("dynamic_options")
275
+ Traceback (most recent call last):
276
+ ...
277
+ edsl.exceptions.questions.QuestionCreationValidationError: ...
278
+ """
279
+ if isinstance(value, str):
280
+ # Check if the string is a dynamic question option
281
+ if "{{" in value and "}}" in value:
282
+ return None
283
+ else:
284
+ raise QuestionCreationValidationError(
285
+ f"Dynamic question options must have jinja2 braces - instead received: {value}."
286
+ )
287
+ if not isinstance(value, list):
288
+ raise QuestionCreationValidationError(
289
+ f"Question options must be a list (got {value})."
290
+ )
291
+ if len(value) > Settings.MAX_NUM_OPTIONS:
292
+ raise QuestionCreationValidationError(
293
+ f"Too many question options (got {value})."
294
+ )
295
+ if len(value) < Settings.MIN_NUM_OPTIONS:
296
+ raise QuestionCreationValidationError(
297
+ f"Too few question options (got {value})."
298
+ )
299
+ # handle the case when question_options is a list of lists (a list of list can be converted to set)
300
+ tmp_value = [str(x) for x in value]
301
+ if len(tmp_value) != len(set(tmp_value)):
302
+ raise QuestionCreationValidationError(
303
+ f"Question options must be unique (got {value})."
304
+ )
305
+ if not self.linear_scale:
306
+ if not self.q_budget:
307
+ pass
308
+ # if not (
309
+ # value
310
+ # and all(type(x) == type(value[0]) for x in value)
311
+ # and isinstance(value[0], (str, list, int, float))
312
+ # ):
313
+ # raise QuestionCreationValidationError(
314
+ # f"Question options must be all same type (got {value}).)"
315
+ # )
316
+ else:
317
+ if not all(isinstance(x, (str)) for x in value):
318
+ raise QuestionCreationValidationError(
319
+ f"Question options must be strings (got {value}).)"
320
+ )
321
+ if not all(
322
+ [
323
+ type(option) != str
324
+ or (len(option) >= 1 and len(option) < Settings.MAX_OPTION_LENGTH)
325
+ for option in value
326
+ ]
327
+ ):
328
+ raise QuestionCreationValidationError(
329
+ f"All question options must be at least 1 character long but less than {Settings.MAX_OPTION_LENGTH} characters long (got {value})."
330
+ )
331
+
332
+ if hasattr(instance, "min_selections") and instance.min_selections != None:
333
+ if instance.min_selections > len(value):
334
+ raise QuestionCreationValidationError(
335
+ f"You asked for at least {instance.min_selections} selections, but provided fewer options (got {value})."
336
+ )
337
+ if hasattr(instance, "max_selections") and instance.max_selections != None:
338
+ if instance.max_selections > len(value):
339
+ raise QuestionCreationValidationError(
340
+ f"You asked for at most {instance.max_selections} selections, but provided fewer options (got {value})."
341
+ )
342
+ if self.num_choices is not None:
343
+ if len(value) != self.num_choices:
344
+ raise QuestionCreationValidationError(
345
+ f"You asked for {self.num_choices} selections, but provided {len(value)} options."
346
+ )
347
+ if self.linear_scale:
348
+ if sorted(value) != list(range(min(value), max(value) + 1)):
349
+ raise QuestionCreationValidationError(
350
+ f"LinearScale.question_options must be a list of successive integers, e.g. [1, 2, 3] (got {value})."
351
+ )
352
+
353
+
354
+ class QuestionTextDescriptor(BaseDescriptor):
355
+ """Validate that the `question_text` attribute is a string.
356
+
357
+
358
+ >>> class TestQuestion:
359
+ ... question_text = QuestionTextDescriptor()
360
+ ... def __init__(self, question_text: str):
361
+ ... self.question_text = question_text
362
+
363
+ >>> _ = TestQuestion("What is the capital of France?")
364
+ >>> _ = TestQuestion("What is the capital of France? {{variable}}")
365
+ >>> _ = TestQuestion("What is the capital of France? {{variable name}}")
366
+ Traceback (most recent call last):
367
+ ...
368
+ edsl.exceptions.questions.QuestionCreationValidationError: Question text contains an invalid identifier: 'variable name'
369
+ """
370
+
371
+ def validate(self, value, instance):
372
+ """Validate the value is a string."""
373
+ # if len(value) > Settings.MAX_QUESTION_LENGTH:
374
+ # raise Exception("Question is too long!")
375
+ if len(value) < 1:
376
+ raise Exception("Question is too short!")
377
+ if not isinstance(value, str):
378
+ raise Exception("Question must be a string!")
379
+ if contains_single_braced_substring(value):
380
+ import warnings
381
+
382
+ # # warnings.warn(
383
+ # # f"WARNING: Question text contains a single-braced substring: If you intended to parameterize the question with a Scenario this should be changed to a double-braced substring, e.g. {{variable}}.\nSee details on constructing Scenarios in the docs: https://docs.expectedparrot.com/en/latest/scenarios.html",
384
+ # # UserWarning,
385
+ # # )
386
+ warnings.warn(
387
+ "WARNING: Question text contains a single-braced substring. "
388
+ "If you intended to parameterize the question with a Scenario, this will "
389
+ "be changed to a double-braced substring, e.g. {{variable}}.\n"
390
+ "See details on constructing Scenarios in the docs: "
391
+ "https://docs.expectedparrot.com/en/latest/scenarios.html",
392
+ UserWarning,
393
+ )
394
+ # Automatically replace single braces with double braces
395
+ # This is here because if the user is using an f-string, the double brace will get converted to a single brace.
396
+ # This undoes that.
397
+ value = re.sub(r"\{([^\{\}]+)\}", r"{{\1}}", value)
398
+ return value
399
+
400
+ # iterate through all doubles braces and check if they are valid python identifiers
401
+ for match in re.finditer(r"\{\{([^\{\}]+)\}\}", value):
402
+ if " " in match.group(1).strip():
403
+ raise QuestionCreationValidationError(
404
+ f"Question text contains an invalid identifier: '{match.group(1)}'"
405
+ )
406
+
407
+ return None
408
+
409
+
410
+ if __name__ == "__main__":
411
+ import doctest
412
+
413
+ doctest.testmod(optionflags=doctest.ELLIPSIS)