edsl 0.1.37.dev5__py3-none-any.whl → 0.1.38__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 (86) hide show
  1. edsl/Base.py +63 -34
  2. edsl/BaseDiff.py +7 -7
  3. edsl/__init__.py +2 -1
  4. edsl/__version__.py +1 -1
  5. edsl/agents/Agent.py +23 -11
  6. edsl/agents/AgentList.py +86 -23
  7. edsl/agents/Invigilator.py +18 -7
  8. edsl/agents/InvigilatorBase.py +0 -19
  9. edsl/agents/PromptConstructor.py +5 -4
  10. edsl/auto/SurveyCreatorPipeline.py +1 -1
  11. edsl/auto/utilities.py +1 -1
  12. edsl/base/Base.py +3 -13
  13. edsl/config.py +8 -0
  14. edsl/coop/coop.py +89 -19
  15. edsl/data/Cache.py +45 -17
  16. edsl/data/CacheEntry.py +8 -3
  17. edsl/data/RemoteCacheSync.py +0 -19
  18. edsl/enums.py +2 -0
  19. edsl/exceptions/agents.py +4 -0
  20. edsl/exceptions/cache.py +5 -0
  21. edsl/inference_services/GoogleService.py +7 -15
  22. edsl/inference_services/PerplexityService.py +163 -0
  23. edsl/inference_services/registry.py +2 -0
  24. edsl/jobs/Jobs.py +110 -559
  25. edsl/jobs/JobsChecks.py +147 -0
  26. edsl/jobs/JobsPrompts.py +268 -0
  27. edsl/jobs/JobsRemoteInferenceHandler.py +239 -0
  28. edsl/jobs/buckets/TokenBucket.py +3 -0
  29. edsl/jobs/interviews/Interview.py +7 -7
  30. edsl/jobs/runners/JobsRunnerAsyncio.py +156 -28
  31. edsl/jobs/runners/JobsRunnerStatus.py +194 -196
  32. edsl/jobs/tasks/TaskHistory.py +27 -19
  33. edsl/language_models/LanguageModel.py +52 -90
  34. edsl/language_models/ModelList.py +67 -14
  35. edsl/language_models/registry.py +57 -4
  36. edsl/notebooks/Notebook.py +7 -8
  37. edsl/prompts/Prompt.py +8 -3
  38. edsl/questions/QuestionBase.py +38 -30
  39. edsl/questions/QuestionBaseGenMixin.py +1 -1
  40. edsl/questions/QuestionBasePromptsMixin.py +0 -17
  41. edsl/questions/QuestionExtract.py +3 -4
  42. edsl/questions/QuestionFunctional.py +10 -3
  43. edsl/questions/derived/QuestionTopK.py +2 -0
  44. edsl/questions/question_registry.py +36 -6
  45. edsl/results/CSSParameterizer.py +108 -0
  46. edsl/results/Dataset.py +146 -15
  47. edsl/results/DatasetExportMixin.py +231 -217
  48. edsl/results/DatasetTree.py +134 -4
  49. edsl/results/Result.py +31 -16
  50. edsl/results/Results.py +159 -65
  51. edsl/results/TableDisplay.py +198 -0
  52. edsl/results/table_display.css +78 -0
  53. edsl/scenarios/FileStore.py +187 -13
  54. edsl/scenarios/Scenario.py +73 -18
  55. edsl/scenarios/ScenarioJoin.py +127 -0
  56. edsl/scenarios/ScenarioList.py +251 -76
  57. edsl/surveys/MemoryPlan.py +1 -1
  58. edsl/surveys/Rule.py +1 -5
  59. edsl/surveys/RuleCollection.py +1 -1
  60. edsl/surveys/Survey.py +25 -19
  61. edsl/surveys/SurveyFlowVisualizationMixin.py +67 -9
  62. edsl/surveys/instructions/ChangeInstruction.py +9 -7
  63. edsl/surveys/instructions/Instruction.py +21 -7
  64. edsl/templates/error_reporting/interview_details.html +3 -3
  65. edsl/templates/error_reporting/interviews.html +18 -9
  66. edsl/{conjure → utilities}/naming_utilities.py +1 -1
  67. edsl/utilities/utilities.py +15 -0
  68. {edsl-0.1.37.dev5.dist-info → edsl-0.1.38.dist-info}/METADATA +2 -1
  69. {edsl-0.1.37.dev5.dist-info → edsl-0.1.38.dist-info}/RECORD +71 -77
  70. edsl/conjure/AgentConstructionMixin.py +0 -160
  71. edsl/conjure/Conjure.py +0 -62
  72. edsl/conjure/InputData.py +0 -659
  73. edsl/conjure/InputDataCSV.py +0 -48
  74. edsl/conjure/InputDataMixinQuestionStats.py +0 -182
  75. edsl/conjure/InputDataPyRead.py +0 -91
  76. edsl/conjure/InputDataSPSS.py +0 -8
  77. edsl/conjure/InputDataStata.py +0 -8
  78. edsl/conjure/QuestionOptionMixin.py +0 -76
  79. edsl/conjure/QuestionTypeMixin.py +0 -23
  80. edsl/conjure/RawQuestion.py +0 -65
  81. edsl/conjure/SurveyResponses.py +0 -7
  82. edsl/conjure/__init__.py +0 -9
  83. edsl/conjure/examples/placeholder.txt +0 -0
  84. edsl/conjure/utilities.py +0 -201
  85. {edsl-0.1.37.dev5.dist-info → edsl-0.1.38.dist-info}/LICENSE +0 -0
  86. {edsl-0.1.37.dev5.dist-info → edsl-0.1.38.dist-info}/WHEEL +0 -0
@@ -138,7 +138,7 @@ class QuestionBaseGenMixin:
138
138
  if exclude_components is None:
139
139
  exclude_components = ["question_name", "question_type"]
140
140
 
141
- d = copy.deepcopy(self._to_dict())
141
+ d = copy.deepcopy(self.to_dict(add_edsl_version=False))
142
142
  for key, value in d.items():
143
143
  if key in exclude_components:
144
144
  continue
@@ -126,7 +126,6 @@ class QuestionBasePromptsMixin:
126
126
 
127
127
  @classmethod
128
128
  def default_question_presentation(cls):
129
- # template_text = cls._read_template("question_presentation.jinja")
130
129
  template_text = template_manager.get_template(
131
130
  cls.question_type, "question_presentation.jinja"
132
131
  )
@@ -142,22 +141,6 @@ class QuestionBasePromptsMixin:
142
141
  def answering_instructions(self, value) -> None:
143
142
  self._answering_instructions = value
144
143
 
145
- # @classmethod
146
- # def default_answering_instructions(cls) -> str:
147
- # with resources.open_text(
148
- # f"edsl.questions.templates.{cls.question_type}",
149
- # "answering_instructions.jinja",
150
- # ) as file:
151
- # return Prompt(text=file.read())
152
-
153
- # @classmethod
154
- # def default_question_presentation(cls):
155
- # with resources.open_text(
156
- # f"edsl.questions.templates.{cls.question_type}",
157
- # "question_presentation.jinja",
158
- # ) as file:
159
- # return Prompt(text=file.read())
160
-
161
144
  @property
162
145
  def question_presentation(self):
163
146
  if self._question_presentation is None:
@@ -1,4 +1,7 @@
1
1
  from __future__ import annotations
2
+ import json
3
+ import re
4
+
2
5
  from typing import Any, Optional, Dict
3
6
  from edsl.questions.QuestionBase import QuestionBase
4
7
  from edsl.questions.descriptors import AnswerTemplateDescriptor
@@ -11,9 +14,6 @@ from edsl.questions.decorators import inject_exception
11
14
  from typing import Dict, Any
12
15
  from pydantic import create_model, Field
13
16
 
14
- import json
15
- import re
16
-
17
17
 
18
18
  def extract_json(text, expected_keys, verbose=False):
19
19
  # Escape special regex characters in keys
@@ -112,7 +112,6 @@ class QuestionExtract(QuestionBase):
112
112
 
113
113
  :param question_name: The name of the question.
114
114
  :param question_text: The text of the question.
115
- :param question_options: The options the respondent should select from.
116
115
  :param answer_template: The template for the answer.
117
116
  """
118
117
  self.question_name = question_name
@@ -108,15 +108,22 @@ class QuestionFunctional(QuestionBase):
108
108
  def question_html_content(self) -> str:
109
109
  return "NA for QuestionFunctional"
110
110
 
111
- @add_edsl_version
112
- def to_dict(self):
113
- return {
111
+ # @add_edsl_version
112
+ def to_dict(self, add_edsl_version=True):
113
+ d = {
114
114
  "question_name": self.question_name,
115
115
  "function_source_code": self.function_source_code,
116
116
  "question_type": "functional",
117
117
  "requires_loop": self.requires_loop,
118
118
  "function_name": self.function_name,
119
119
  }
120
+ if add_edsl_version:
121
+ from edsl import __version__
122
+
123
+ d["edsl_version"] = __version__
124
+ d["edsl_class_name"] = self.__class__.__name__
125
+
126
+ return d
120
127
 
121
128
  @classmethod
122
129
  def example(cls):
@@ -21,6 +21,7 @@ class QuestionTopK(QuestionCheckBox):
21
21
  question_presentation: Optional[str] = None,
22
22
  answering_instructions: Optional[str] = None,
23
23
  include_comment: Optional[bool] = True,
24
+ use_code: Optional[bool] = True,
24
25
  ):
25
26
  """Initialize the question.
26
27
 
@@ -39,6 +40,7 @@ class QuestionTopK(QuestionCheckBox):
39
40
  question_presentation=question_presentation,
40
41
  answering_instructions=answering_instructions,
41
42
  include_comment=include_comment,
43
+ use_code=use_code,
42
44
  )
43
45
  if min_selections != max_selections:
44
46
  raise QuestionCreationValidationError(
@@ -90,6 +90,22 @@ class Question(metaclass=Meta):
90
90
  coop = Coop()
91
91
  return coop.patch(uuid, url, description, value, visibility)
92
92
 
93
+ @classmethod
94
+ def list_question_types(cls):
95
+ """Return a list of available question types.
96
+
97
+ >>> from edsl import Question
98
+ >>> Question.list_question_types()
99
+ ['checkbox', 'extract', 'free_text', 'functional', 'likert_five', 'linear_scale', 'list', 'multiple_choice', 'numerical', 'rank', 'top_k', 'yes_no']
100
+ """
101
+ return [
102
+ q
103
+ for q in sorted(
104
+ list(RegisterQuestionsMeta.question_types_to_classes().keys())
105
+ )
106
+ if q not in ["budget"]
107
+ ]
108
+
93
109
  @classmethod
94
110
  def available(cls, show_class_names: bool = False) -> Union[list, dict]:
95
111
  """Return a list of available question types.
@@ -98,18 +114,32 @@ class Question(metaclass=Meta):
98
114
 
99
115
  Example usage:
100
116
 
101
- >>> from edsl import Question
102
- >>> Question.available()
103
- ['checkbox', 'extract', 'free_text', 'functional', 'likert_five', 'linear_scale', 'list', 'multiple_choice', 'numerical', 'rank', 'top_k', 'yes_no']
104
117
  """
118
+ from edsl.results.Dataset import Dataset
119
+
105
120
  exclude = ["budget"]
106
121
  if show_class_names:
107
122
  return RegisterQuestionsMeta.question_types_to_classes()
108
123
  else:
109
- question_list = sorted(
110
- set(RegisterQuestionsMeta.question_types_to_classes().keys())
124
+ question_list = [
125
+ q
126
+ for q in sorted(
127
+ set(RegisterQuestionsMeta.question_types_to_classes().keys())
128
+ )
129
+ if q not in exclude
130
+ ]
131
+ d = RegisterQuestionsMeta.question_types_to_classes()
132
+ question_classes = [d[q] for q in question_list]
133
+ example_questions = [repr(q.example()) for q in question_classes]
134
+
135
+ return Dataset(
136
+ [
137
+ {"question_type": [q for q in question_list]},
138
+ {"question_class": [q.__name__ for q in question_classes]},
139
+ {"example_question": example_questions},
140
+ ],
141
+ print_parameters={"containerHeight": "auto"},
111
142
  )
112
- return [q for q in question_list if q not in exclude]
113
143
 
114
144
 
115
145
  def get_question_class(question_type):
@@ -0,0 +1,108 @@
1
+ import re
2
+ from typing import Dict, Set, Optional
3
+
4
+
5
+ class CSSParameterizer:
6
+ """A utility class to parameterize CSS with custom properties (variables)."""
7
+
8
+ def __init__(self, css_content: str):
9
+ """
10
+ Initialize with CSS content to be parameterized.
11
+
12
+ Args:
13
+ css_content (str): The CSS content containing var() declarations
14
+ """
15
+ self.css_content = css_content
16
+ self._extract_variables()
17
+
18
+ def _extract_variables(self) -> None:
19
+ """Extract all CSS custom properties (variables) from the CSS content."""
20
+ # Find all var(...) declarations in the CSS
21
+ var_pattern = r"var\((--[a-zA-Z0-9-]+)\)"
22
+ self.variables = set(re.findall(var_pattern, self.css_content))
23
+
24
+ def _validate_parameters(self, parameters: Dict[str, str]) -> Set[str]:
25
+ """
26
+ Validate the provided parameters against the CSS variables.
27
+
28
+ Args:
29
+ parameters (Dict[str, str]): Dictionary of variable names and their values
30
+
31
+ Returns:
32
+ Set[str]: Set of missing variables
33
+ """
34
+ # Convert parameter keys to CSS variable format if they don't already have --
35
+ formatted_params = {
36
+ f"--{k}" if not k.startswith("--") else k for k in parameters.keys()
37
+ }
38
+
39
+ # print("Variables from CSS:", self.variables)
40
+ # print("Formatted parameters:", formatted_params)
41
+
42
+ # Find missing and extra variables
43
+ missing_vars = self.variables - formatted_params
44
+ extra_vars = formatted_params - self.variables
45
+
46
+ if extra_vars:
47
+ print(f"Warning: Found unused parameters: {extra_vars}")
48
+
49
+ return missing_vars
50
+
51
+ def generate_root(self, **parameters: str) -> Optional[str]:
52
+ """
53
+ Generate a :root block with the provided parameters.
54
+
55
+ Args:
56
+ **parameters: Keyword arguments where keys are variable names and values are their values
57
+
58
+ Returns:
59
+ str: Generated :root block with variables, or None if validation fails
60
+
61
+ Example:
62
+ >>> css = "body { height: var(--bodyHeight); }"
63
+ >>> parameterizer = CSSParameterizer(css)
64
+ >>> parameterizer.apply_parameters({'bodyHeight':"100vh"})
65
+ ':root {\\n --bodyHeight: 100vh;\\n}\\n\\nbody { height: var(--bodyHeight); }'
66
+ """
67
+ missing_vars = self._validate_parameters(parameters)
68
+
69
+ if missing_vars:
70
+ print(f"Error: Missing required variables: {missing_vars}")
71
+ return None
72
+
73
+ # Format parameters with -- prefix if not present
74
+ formatted_params = {
75
+ f"--{k}" if not k.startswith("--") else k: v for k, v in parameters.items()
76
+ }
77
+
78
+ # Generate the :root block
79
+ root_block = [":root {"]
80
+ for var_name, value in sorted(formatted_params.items()):
81
+ if var_name in self.variables:
82
+ root_block.append(f" {var_name}: {value};")
83
+ root_block.append("}")
84
+
85
+ return "\n".join(root_block)
86
+
87
+ def apply_parameters(self, parameters: dict) -> Optional[str]:
88
+ """
89
+ Generate the complete CSS with the :root block and original CSS content.
90
+
91
+ Args:
92
+ **parameters: Keyword arguments where keys are variable names and values are their values
93
+
94
+ Returns:
95
+ str: Complete CSS with :root block and original content, or None if validation fails
96
+ """
97
+ root_block = self.generate_root(**parameters)
98
+ if root_block is None:
99
+ return None
100
+
101
+ return f"{root_block}\n\n{self.css_content}"
102
+
103
+
104
+ # Example usage
105
+ if __name__ == "__main__":
106
+ import doctest
107
+
108
+ doctest.testmod()
edsl/results/Dataset.py CHANGED
@@ -5,19 +5,23 @@ import random
5
5
  import json
6
6
  from collections import UserList
7
7
  from typing import Any, Union, Optional
8
-
8
+ import sys
9
9
  import numpy as np
10
10
 
11
11
  from edsl.results.ResultsExportMixin import ResultsExportMixin
12
12
  from edsl.results.DatasetTree import Tree
13
+ from edsl.results.TableDisplay import TableDisplay
13
14
 
14
15
 
15
16
  class Dataset(UserList, ResultsExportMixin):
16
17
  """A class to represent a dataset of observations."""
17
18
 
18
- def __init__(self, data: list[dict[str, Any]] = None):
19
+ def __init__(
20
+ self, data: list[dict[str, Any]] = None, print_parameters: Optional[dict] = None
21
+ ):
19
22
  """Initialize the dataset with the given data."""
20
23
  super().__init__(data)
24
+ self.print_parameters = print_parameters
21
25
 
22
26
  def __len__(self) -> int:
23
27
  """Return the number of observations in the dataset.
@@ -32,7 +36,7 @@ class Dataset(UserList, ResultsExportMixin):
32
36
  _, values = list(self.data[0].items())[0]
33
37
  return len(values)
34
38
 
35
- def keys(self):
39
+ def keys(self) -> list[str]:
36
40
  """Return the keys of the first observation in the dataset.
37
41
 
38
42
  >>> d = Dataset([{'a.b':[1,2,3,4]}])
@@ -41,10 +45,45 @@ class Dataset(UserList, ResultsExportMixin):
41
45
  """
42
46
  return [list(o.keys())[0] for o in self]
43
47
 
48
+ def filter(self, expression):
49
+ return self.to_scenario_list().filter(expression).to_dataset()
50
+
44
51
  def __repr__(self) -> str:
45
52
  """Return a string representation of the dataset."""
46
53
  return f"Dataset({self.data})"
47
54
 
55
+ def write(self, filename: str, tablefmt: Optional[str] = None) -> None:
56
+ return self.table(tablefmt=tablefmt).write(filename)
57
+
58
+ def _repr_html_(self):
59
+ # headers, data = self._tabular()
60
+ return self.table(print_parameters=self.print_parameters)._repr_html_()
61
+ # return TableDisplay(headers=headers, data=data, raw_data_set=self)
62
+
63
+ def _tabular(self) -> tuple[list[str], list[list[Any]]]:
64
+ # Extract headers
65
+ headers = []
66
+ for entry in self.data:
67
+ headers.extend(entry.keys())
68
+ headers = list(dict.fromkeys(headers)) # Ensure unique headers
69
+
70
+ # Extract data
71
+ max_len = max(len(values) for entry in self.data for values in entry.values())
72
+ rows = []
73
+ for i in range(max_len):
74
+ row = []
75
+ for header in headers:
76
+ for entry in self.data:
77
+ if header in entry:
78
+ values = entry[header]
79
+ row.append(values[i] if i < len(values) else None)
80
+ break
81
+ else:
82
+ row.append(None) # Default to None if header is missing
83
+ rows.append(row)
84
+
85
+ return headers, rows
86
+
48
87
  def _key_to_value(self, key: str) -> Any:
49
88
  """Retrieve the value associated with the given key from the dataset.
50
89
 
@@ -89,7 +128,25 @@ class Dataset(UserList, ResultsExportMixin):
89
128
 
90
129
  return get_values(self.data[0])[0]
91
130
 
92
- def select(self, *keys):
131
+ def print(self, pretty_labels=None, **kwargs):
132
+ if "format" in kwargs:
133
+ if kwargs["format"] not in ["html", "markdown", "rich", "latex"]:
134
+ raise ValueError(f"Format '{kwargs['format']}' not supported.")
135
+ if pretty_labels is None:
136
+ pretty_labels = {}
137
+ else:
138
+ return self.rename(pretty_labels).print(**kwargs)
139
+ return self.table()
140
+
141
+ def rename(self, rename_dic) -> Dataset:
142
+ new_data = []
143
+ for observation in self.data:
144
+ key, values = list(observation.items())[0]
145
+ new_key = rename_dic.get(key, key)
146
+ new_data.append({new_key: values})
147
+ return Dataset(new_data)
148
+
149
+ def select(self, *keys) -> Dataset:
93
150
  """Return a new dataset with only the selected keys.
94
151
 
95
152
  :param keys: The keys to select.
@@ -122,12 +179,6 @@ class Dataset(UserList, ResultsExportMixin):
122
179
  json.dumps(self.data)
123
180
  ) # janky but I want to make sure it's serializable & deserializable
124
181
 
125
- def _repr_html_(self) -> str:
126
- """Return an HTML representation of the dataset."""
127
- from edsl.utilities.utilities import data_to_html
128
-
129
- return data_to_html(self.data)
130
-
131
182
  def shuffle(self, seed=None) -> Dataset:
132
183
  """Return a new dataset with the observations shuffled.
133
184
 
@@ -149,6 +200,9 @@ class Dataset(UserList, ResultsExportMixin):
149
200
 
150
201
  return self
151
202
 
203
+ def expand(self, field):
204
+ return self.to_scenario_list().expand(field).to_dataset()
205
+
152
206
  def sample(
153
207
  self,
154
208
  n: int = None,
@@ -267,15 +321,92 @@ class Dataset(UserList, ResultsExportMixin):
267
321
 
268
322
  return Dataset(new_data)
269
323
 
270
- @property
271
- def tree(self):
324
+ def tree(self, node_order: Optional[list[str]] = None) -> Tree:
272
325
  """Return a tree representation of the dataset.
273
326
 
274
327
  >>> d = Dataset([{'a':[1,2,3,4]}, {'b':[4,3,2,1]}])
275
- >>> d.tree.print_tree()
276
- Tree has not been constructed yet.
328
+ >>> d.tree()
329
+ Tree(Dataset({'a': [1, 2, 3, 4], 'b': [4, 3, 2, 1]}))
277
330
  """
278
- return Tree(self)
331
+ return Tree(self, node_order=node_order)
332
+
333
+ def table(
334
+ self,
335
+ *fields,
336
+ tablefmt: Optional[str] = None,
337
+ max_rows: Optional[int] = None,
338
+ pretty_labels=None,
339
+ print_parameters: Optional[dict] = None,
340
+ ):
341
+ if pretty_labels is not None:
342
+ new_fields = []
343
+ for field in fields:
344
+ new_fields.append(pretty_labels.get(field, field))
345
+ return self.rename(pretty_labels).table(
346
+ *new_fields, tablefmt=tablefmt, max_rows=max_rows
347
+ )
348
+
349
+ self.print_parameters = print_parameters
350
+
351
+ headers, data = self._tabular()
352
+
353
+ if tablefmt is not None:
354
+ from tabulate import tabulate_formats
355
+
356
+ if tablefmt not in tabulate_formats:
357
+ print(
358
+ f"Error: The following table format is not supported: {tablefmt}",
359
+ file=sys.stderr,
360
+ )
361
+ print(f"\nAvailable formats are: {tabulate_formats}", file=sys.stderr)
362
+ return None
363
+
364
+ if max_rows:
365
+ if len(data) < max_rows:
366
+ max_rows = None
367
+
368
+ if fields:
369
+ full_data = data
370
+ data = []
371
+ indices = []
372
+ for field in fields:
373
+ if field not in headers:
374
+ print(
375
+ f"Error: The following field was not found: {field}",
376
+ file=sys.stderr,
377
+ )
378
+ print(f"\nAvailable fields are: {headers}", file=sys.stderr)
379
+
380
+ # Optional: Suggest similar fields using difflib
381
+ import difflib
382
+
383
+ matches = difflib.get_close_matches(field, headers)
384
+ if matches:
385
+ print(f"\nDid you mean: {matches[0]} ?", file=sys.stderr)
386
+ return None
387
+ indices.append(headers.index(field))
388
+ headers = fields
389
+ for row in full_data:
390
+ data.append([row[i] for i in indices])
391
+
392
+ if max_rows is not None:
393
+ if max_rows > len(data):
394
+ raise ValueError(
395
+ "max_rows cannot be greater than the number of rows in the dataset."
396
+ )
397
+ last_line = data[-1]
398
+ spaces = len(data[max_rows])
399
+ filler_line = ["." for i in range(spaces)]
400
+ data = data[:max_rows]
401
+ data.append(filler_line)
402
+ data.append(last_line)
403
+
404
+ return TableDisplay(
405
+ data=data, headers=headers, tablefmt=tablefmt, raw_data_set=self
406
+ )
407
+
408
+ def summary(self):
409
+ return Dataset([{"num_observations": [len(self)], "keys": [self.keys()]}])
279
410
 
280
411
  @classmethod
281
412
  def example(self):