lms.model.base
1import typing 2 3import edq.util.json 4import edq.util.time 5 6import lms.model.constants 7import lms.util.string 8 9TEXT_SEPARATOR: str = ': ' 10TEXT_EMPTY_VALUE: str = '' 11 12T = typing.TypeVar('T', bound = 'BaseType') 13 14class BaseType(edq.util.json.DictConverter): 15 """ 16 The base class for all core LMS types. 17 This class ensures that all children have the core functionality necessary for this package. 18 19 The typical structure of types in this package is that types in the model package extend this class. 20 Then, backends may declare their own types that extend the other classes from the model package. 21 For example: lms.model.base.BaseType -> lms.model.assignments.Assignment -> lms.backend.canvas.model.assignments.Assignment 22 23 General (but less efficient) implementations of core functions will be provided. 24 """ 25 26 CORE_FIELDS: typing.List[str] = [] 27 """ 28 The common fields shared across backends for this type that are used for comparison and other operations. 29 Child classes should set this to define how comparisons are made. 30 """ 31 32 INT_COMPARISON_FIELDS: typing.Set[str] = {'id'} 33 """ 34 Fields that should be compared like ints (even if they are strings). 35 By default, this set will include 'id'. 36 """ 37 38 def __init__(self, 39 **kwargs: typing.Any) -> None: 40 self.extra_fields: typing.Dict[str, typing.Any] = kwargs.copy() 41 """ Additional fields not common to all backends or explicitly used by the creating child backend. """ 42 43 def __eq__(self, other: object) -> bool: 44 if (not isinstance(other, BaseType)): 45 return False 46 47 # Check the specified fields only. 48 for field_name in self.CORE_FIELDS: 49 if (not hasattr(other, field_name)): 50 return False 51 52 value_self = getattr(self, field_name) 53 value_other = getattr(other, field_name) 54 55 if (field_name in self.INT_COMPARISON_FIELDS): 56 comparison = lms.util.string.compare_maybe_ints(value_self, value_other) 57 if (comparison != 0): 58 return False 59 elif (value_self != value_other): 60 return False 61 62 return True 63 64 def __hash__(self) -> int: 65 values = tuple(getattr(self, field_name) for field_name in self.CORE_FIELDS) 66 return hash(values) 67 68 def __lt__(self, other: 'BaseType') -> bool: # type: ignore[override] 69 if (not isinstance(other, BaseType)): 70 return False 71 72 # Check the specified fields only. 73 for field_name in self.CORE_FIELDS: 74 if (not hasattr(other, field_name)): 75 return False 76 77 value_self = getattr(self, field_name) 78 value_other = getattr(other, field_name) 79 80 if (field_name in self.INT_COMPARISON_FIELDS): 81 comparison = lms.util.string.compare_maybe_ints(value_self, value_other) 82 if (comparison == 0): 83 continue 84 85 return (comparison < 0) 86 87 if (value_self == value_other): 88 continue 89 90 return bool(value_self < value_other) 91 92 return False 93 94 def as_text_rows(self, 95 skip_headers: bool = False, 96 pretty_headers: bool = False, 97 **kwargs: typing.Any) -> typing.List[str]: 98 """ 99 Create a representation of this object in the "text" style of this project meant for display. 100 A list of rows will be returned. 101 """ 102 103 rows = [] 104 105 kwargs['pretty_timestamps'] = True 106 107 for (field_name, row) in self._get_fields(**kwargs).items(): 108 if (not skip_headers): 109 header = field_name 110 if (pretty_headers): 111 header = header.replace('_', ' ').title() 112 113 row = f"{header}{TEXT_SEPARATOR}{row}" 114 115 rows.append(row) 116 117 return rows 118 119 def get_headers(self, 120 pretty_headers: bool = False, 121 **kwargs: typing.Any) -> typing.List[str]: 122 """ 123 Get a list of headers to label the values represented by this object meant for display. 124 This method is a companion to as_table_rows(), 125 given the same options these two methods will produce rows with the same length and ordering. 126 """ 127 128 headers = [] 129 130 for field_name in self._get_fields(**kwargs): 131 header = field_name 132 if (pretty_headers): 133 header = header.replace('_', ' ').title() 134 135 headers.append(header) 136 137 return headers 138 139 def as_table_rows(self, 140 **kwargs: typing.Any) -> typing.List[typing.List[str]]: 141 """ 142 Get a list of the values by this object meant for display. 143 This method is a companion to get_headers(), 144 given the same options these two methods will produce rows with the same length and ordering. 145 146 Note that the default implementation for this method always return a single row, 147 but children may override and return multiple rows per object. 148 """ 149 150 return [list(self._get_fields(**kwargs).values())] 151 152 def as_json_dict(self, 153 **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: 154 """ 155 Get a dict representation of this object meant for display as JSON. 156 (Note that we are not returning JSON, just a dict that is ready to be converted to JSON.) 157 Calling this method differs from passing this object to json.dumps() (or any sibling), 158 because this method may not include all fields, may flatten or alter fields, and will order fields differently. 159 """ 160 161 return {field_name: self._get_field_value(field_name) for field_name in self._get_fields(**kwargs)} 162 163 def _get_fields(self, 164 include_extra_fields: bool = False, 165 **kwargs: typing.Any) -> typing.Dict[str, str]: 166 """ 167 Get a dictionary representing the "target" fields of this object meant for display. 168 Keys (field names) will not be modified, but values will be sent to self._value_to_text(). 169 Keys are placed in the dictionary in a consistent ordering. 170 """ 171 172 field_names = self.CORE_FIELDS.copy() 173 174 # Append any extra fields after the core fields. 175 if (include_extra_fields): 176 # First, include any fields that are not in self.extra_fields. 177 for extra_name in (list(vars(self).keys()) + list(self.extra_fields.keys())): 178 if (extra_name == 'extra_fields'): 179 continue 180 181 if (extra_name not in field_names): 182 field_names.append(extra_name) 183 184 fields = {} 185 for field_name in field_names: 186 fields[field_name] = self._value_to_text(self._get_field_value(field_name), **kwargs) 187 188 return fields 189 190 def _get_field_value(self, name: str, default: typing.Any = None) -> typing.Any: 191 """ 192 Get the value for a field. 193 This is similar to `getattr(self, name, default)`, 194 but this will also check `extra_fields` if the field is not found at the top level. 195 """ 196 197 if (hasattr(self, name)): 198 return getattr(self, name) 199 200 if (name in self.extra_fields): 201 return self.extra_fields[name] 202 203 return default 204 205 def _value_to_text(self, 206 value: typing.Any, 207 indent: typing.Union[int, None] = None, 208 pretty_timestamps: bool = False, 209 **kwargs: typing.Any) -> str: 210 """ 211 Convert some arbitrary value (usually found within a BaseType) to a string. 212 None values will be returned as `TEXT_EMPTY_VALUE`. 213 """ 214 215 if (value is None): 216 return TEXT_EMPTY_VALUE 217 218 if (hasattr(value, '_to_text')): 219 return str(value._to_text()) 220 221 if (isinstance(value, (edq.util.json.DictConverter, dict, list, tuple))): 222 return str(edq.util.json.dumps(value, indent = indent)) 223 224 if (pretty_timestamps and isinstance(value, edq.util.time.Timestamp)): 225 return value.pretty(short = True) 226 227 return str(value) 228 229 @classmethod 230 def from_json_dict(cls: typing.Type[T], 231 data: typing.Dict[str, typing.Any], 232 **kwargs: typing.Any) -> T: 233 """ 234 Create an object from a dict that can be used for JSON. 235 This is the inverse of as_json_dict(). 236 """ 237 238 return typing.cast(T, cls.from_dict(data)) 239 240def base_list_to_output_format(values: typing.Sequence[BaseType], output_format: str, 241 sort: bool = True, 242 skip_headers: bool = False, 243 pretty_headers: bool = False, 244 include_extra_fields: bool = False, 245 **kwargs: typing.Any) -> str: 246 """ 247 Convert a list of base types to a string representation. 248 The returned string will not include a trailing newline. 249 250 The given list may be modified by this call. 251 """ 252 253 values = list(values) 254 255 if (sort): 256 values.sort() 257 258 output = '' 259 260 if (output_format == lms.model.constants.OUTPUT_FORMAT_JSON): 261 output = base_list_to_json(values, 262 include_extra_fields = include_extra_fields, 263 **kwargs) 264 elif (output_format == lms.model.constants.OUTPUT_FORMAT_TABLE): 265 output = base_list_to_table(values, 266 skip_headers = skip_headers, pretty_headers = pretty_headers, 267 include_extra_fields = include_extra_fields, 268 **kwargs) 269 elif (output_format == lms.model.constants.OUTPUT_FORMAT_TEXT): 270 output = base_list_to_text(values, 271 skip_headers = skip_headers, pretty_headers = pretty_headers, 272 include_extra_fields = include_extra_fields, 273 **kwargs) 274 else: 275 raise ValueError(f"Unknown output format: '{output_format}'.") 276 277 return output 278 279def base_list_to_json(values: typing.Sequence[BaseType], 280 indent: int = 4, 281 extract_single_list: bool = False, 282 **kwargs: typing.Any) -> str: 283 """ Convert a list of base types to a JSON string representation. """ 284 285 output_values = [value.as_json_dict(**kwargs) for value in values] 286 if (extract_single_list and (len(output_values) == 1)): 287 output_values = output_values[0] # type: ignore[assignment] 288 289 return str(edq.util.json.dumps(output_values, indent = indent, sort_keys = False)) 290 291def base_list_to_table(values: typing.Sequence[BaseType], 292 skip_headers: bool = False, 293 delim: str = "\t", 294 **kwargs: typing.Any) -> str: 295 """ Convert a list of base types to a table string representation. """ 296 297 rows = [] 298 299 if ((len(values) > 0) and (not skip_headers)): 300 rows.append(values[0].get_headers(**kwargs)) 301 302 for value in values: 303 rows += value.as_table_rows(**kwargs) 304 305 return "\n".join([delim.join(row) for row in rows]) 306 307def base_list_to_text(values: typing.Sequence[BaseType], 308 **kwargs: typing.Any) -> str: 309 """ Convert a list of base types to a text string representation. """ 310 311 output = [] 312 313 for value in values: 314 rows = value.as_text_rows(**kwargs) 315 output.append("\n".join(rows)) 316 317 return "\n\n".join(output)
15class BaseType(edq.util.json.DictConverter): 16 """ 17 The base class for all core LMS types. 18 This class ensures that all children have the core functionality necessary for this package. 19 20 The typical structure of types in this package is that types in the model package extend this class. 21 Then, backends may declare their own types that extend the other classes from the model package. 22 For example: lms.model.base.BaseType -> lms.model.assignments.Assignment -> lms.backend.canvas.model.assignments.Assignment 23 24 General (but less efficient) implementations of core functions will be provided. 25 """ 26 27 CORE_FIELDS: typing.List[str] = [] 28 """ 29 The common fields shared across backends for this type that are used for comparison and other operations. 30 Child classes should set this to define how comparisons are made. 31 """ 32 33 INT_COMPARISON_FIELDS: typing.Set[str] = {'id'} 34 """ 35 Fields that should be compared like ints (even if they are strings). 36 By default, this set will include 'id'. 37 """ 38 39 def __init__(self, 40 **kwargs: typing.Any) -> None: 41 self.extra_fields: typing.Dict[str, typing.Any] = kwargs.copy() 42 """ Additional fields not common to all backends or explicitly used by the creating child backend. """ 43 44 def __eq__(self, other: object) -> bool: 45 if (not isinstance(other, BaseType)): 46 return False 47 48 # Check the specified fields only. 49 for field_name in self.CORE_FIELDS: 50 if (not hasattr(other, field_name)): 51 return False 52 53 value_self = getattr(self, field_name) 54 value_other = getattr(other, field_name) 55 56 if (field_name in self.INT_COMPARISON_FIELDS): 57 comparison = lms.util.string.compare_maybe_ints(value_self, value_other) 58 if (comparison != 0): 59 return False 60 elif (value_self != value_other): 61 return False 62 63 return True 64 65 def __hash__(self) -> int: 66 values = tuple(getattr(self, field_name) for field_name in self.CORE_FIELDS) 67 return hash(values) 68 69 def __lt__(self, other: 'BaseType') -> bool: # type: ignore[override] 70 if (not isinstance(other, BaseType)): 71 return False 72 73 # Check the specified fields only. 74 for field_name in self.CORE_FIELDS: 75 if (not hasattr(other, field_name)): 76 return False 77 78 value_self = getattr(self, field_name) 79 value_other = getattr(other, field_name) 80 81 if (field_name in self.INT_COMPARISON_FIELDS): 82 comparison = lms.util.string.compare_maybe_ints(value_self, value_other) 83 if (comparison == 0): 84 continue 85 86 return (comparison < 0) 87 88 if (value_self == value_other): 89 continue 90 91 return bool(value_self < value_other) 92 93 return False 94 95 def as_text_rows(self, 96 skip_headers: bool = False, 97 pretty_headers: bool = False, 98 **kwargs: typing.Any) -> typing.List[str]: 99 """ 100 Create a representation of this object in the "text" style of this project meant for display. 101 A list of rows will be returned. 102 """ 103 104 rows = [] 105 106 kwargs['pretty_timestamps'] = True 107 108 for (field_name, row) in self._get_fields(**kwargs).items(): 109 if (not skip_headers): 110 header = field_name 111 if (pretty_headers): 112 header = header.replace('_', ' ').title() 113 114 row = f"{header}{TEXT_SEPARATOR}{row}" 115 116 rows.append(row) 117 118 return rows 119 120 def get_headers(self, 121 pretty_headers: bool = False, 122 **kwargs: typing.Any) -> typing.List[str]: 123 """ 124 Get a list of headers to label the values represented by this object meant for display. 125 This method is a companion to as_table_rows(), 126 given the same options these two methods will produce rows with the same length and ordering. 127 """ 128 129 headers = [] 130 131 for field_name in self._get_fields(**kwargs): 132 header = field_name 133 if (pretty_headers): 134 header = header.replace('_', ' ').title() 135 136 headers.append(header) 137 138 return headers 139 140 def as_table_rows(self, 141 **kwargs: typing.Any) -> typing.List[typing.List[str]]: 142 """ 143 Get a list of the values by this object meant for display. 144 This method is a companion to get_headers(), 145 given the same options these two methods will produce rows with the same length and ordering. 146 147 Note that the default implementation for this method always return a single row, 148 but children may override and return multiple rows per object. 149 """ 150 151 return [list(self._get_fields(**kwargs).values())] 152 153 def as_json_dict(self, 154 **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: 155 """ 156 Get a dict representation of this object meant for display as JSON. 157 (Note that we are not returning JSON, just a dict that is ready to be converted to JSON.) 158 Calling this method differs from passing this object to json.dumps() (or any sibling), 159 because this method may not include all fields, may flatten or alter fields, and will order fields differently. 160 """ 161 162 return {field_name: self._get_field_value(field_name) for field_name in self._get_fields(**kwargs)} 163 164 def _get_fields(self, 165 include_extra_fields: bool = False, 166 **kwargs: typing.Any) -> typing.Dict[str, str]: 167 """ 168 Get a dictionary representing the "target" fields of this object meant for display. 169 Keys (field names) will not be modified, but values will be sent to self._value_to_text(). 170 Keys are placed in the dictionary in a consistent ordering. 171 """ 172 173 field_names = self.CORE_FIELDS.copy() 174 175 # Append any extra fields after the core fields. 176 if (include_extra_fields): 177 # First, include any fields that are not in self.extra_fields. 178 for extra_name in (list(vars(self).keys()) + list(self.extra_fields.keys())): 179 if (extra_name == 'extra_fields'): 180 continue 181 182 if (extra_name not in field_names): 183 field_names.append(extra_name) 184 185 fields = {} 186 for field_name in field_names: 187 fields[field_name] = self._value_to_text(self._get_field_value(field_name), **kwargs) 188 189 return fields 190 191 def _get_field_value(self, name: str, default: typing.Any = None) -> typing.Any: 192 """ 193 Get the value for a field. 194 This is similar to `getattr(self, name, default)`, 195 but this will also check `extra_fields` if the field is not found at the top level. 196 """ 197 198 if (hasattr(self, name)): 199 return getattr(self, name) 200 201 if (name in self.extra_fields): 202 return self.extra_fields[name] 203 204 return default 205 206 def _value_to_text(self, 207 value: typing.Any, 208 indent: typing.Union[int, None] = None, 209 pretty_timestamps: bool = False, 210 **kwargs: typing.Any) -> str: 211 """ 212 Convert some arbitrary value (usually found within a BaseType) to a string. 213 None values will be returned as `TEXT_EMPTY_VALUE`. 214 """ 215 216 if (value is None): 217 return TEXT_EMPTY_VALUE 218 219 if (hasattr(value, '_to_text')): 220 return str(value._to_text()) 221 222 if (isinstance(value, (edq.util.json.DictConverter, dict, list, tuple))): 223 return str(edq.util.json.dumps(value, indent = indent)) 224 225 if (pretty_timestamps and isinstance(value, edq.util.time.Timestamp)): 226 return value.pretty(short = True) 227 228 return str(value) 229 230 @classmethod 231 def from_json_dict(cls: typing.Type[T], 232 data: typing.Dict[str, typing.Any], 233 **kwargs: typing.Any) -> T: 234 """ 235 Create an object from a dict that can be used for JSON. 236 This is the inverse of as_json_dict(). 237 """ 238 239 return typing.cast(T, cls.from_dict(data))
The base class for all core LMS types. This class ensures that all children have the core functionality necessary for this package.
The typical structure of types in this package is that types in the model package extend this class. Then, backends may declare their own types that extend the other classes from the model package. For example: lms.model.base.BaseType -> lms.model.assignments.Assignment -> lms.backend.canvas.model.assignments.Assignment
General (but less efficient) implementations of core functions will be provided.
The common fields shared across backends for this type that are used for comparison and other operations. Child classes should set this to define how comparisons are made.
Fields that should be compared like ints (even if they are strings). By default, this set will include 'id'.
Additional fields not common to all backends or explicitly used by the creating child backend.
95 def as_text_rows(self, 96 skip_headers: bool = False, 97 pretty_headers: bool = False, 98 **kwargs: typing.Any) -> typing.List[str]: 99 """ 100 Create a representation of this object in the "text" style of this project meant for display. 101 A list of rows will be returned. 102 """ 103 104 rows = [] 105 106 kwargs['pretty_timestamps'] = True 107 108 for (field_name, row) in self._get_fields(**kwargs).items(): 109 if (not skip_headers): 110 header = field_name 111 if (pretty_headers): 112 header = header.replace('_', ' ').title() 113 114 row = f"{header}{TEXT_SEPARATOR}{row}" 115 116 rows.append(row) 117 118 return rows
Create a representation of this object in the "text" style of this project meant for display. A list of rows will be returned.
120 def get_headers(self, 121 pretty_headers: bool = False, 122 **kwargs: typing.Any) -> typing.List[str]: 123 """ 124 Get a list of headers to label the values represented by this object meant for display. 125 This method is a companion to as_table_rows(), 126 given the same options these two methods will produce rows with the same length and ordering. 127 """ 128 129 headers = [] 130 131 for field_name in self._get_fields(**kwargs): 132 header = field_name 133 if (pretty_headers): 134 header = header.replace('_', ' ').title() 135 136 headers.append(header) 137 138 return headers
Get a list of headers to label the values represented by this object meant for display. This method is a companion to as_table_rows(), given the same options these two methods will produce rows with the same length and ordering.
140 def as_table_rows(self, 141 **kwargs: typing.Any) -> typing.List[typing.List[str]]: 142 """ 143 Get a list of the values by this object meant for display. 144 This method is a companion to get_headers(), 145 given the same options these two methods will produce rows with the same length and ordering. 146 147 Note that the default implementation for this method always return a single row, 148 but children may override and return multiple rows per object. 149 """ 150 151 return [list(self._get_fields(**kwargs).values())]
Get a list of the values by this object meant for display. This method is a companion to get_headers(), given the same options these two methods will produce rows with the same length and ordering.
Note that the default implementation for this method always return a single row, but children may override and return multiple rows per object.
153 def as_json_dict(self, 154 **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: 155 """ 156 Get a dict representation of this object meant for display as JSON. 157 (Note that we are not returning JSON, just a dict that is ready to be converted to JSON.) 158 Calling this method differs from passing this object to json.dumps() (or any sibling), 159 because this method may not include all fields, may flatten or alter fields, and will order fields differently. 160 """ 161 162 return {field_name: self._get_field_value(field_name) for field_name in self._get_fields(**kwargs)}
Get a dict representation of this object meant for display as JSON. (Note that we are not returning JSON, just a dict that is ready to be converted to JSON.) Calling this method differs from passing this object to json.dumps() (or any sibling), because this method may not include all fields, may flatten or alter fields, and will order fields differently.
230 @classmethod 231 def from_json_dict(cls: typing.Type[T], 232 data: typing.Dict[str, typing.Any], 233 **kwargs: typing.Any) -> T: 234 """ 235 Create an object from a dict that can be used for JSON. 236 This is the inverse of as_json_dict(). 237 """ 238 239 return typing.cast(T, cls.from_dict(data))
Create an object from a dict that can be used for JSON. This is the inverse of as_json_dict().
241def base_list_to_output_format(values: typing.Sequence[BaseType], output_format: str, 242 sort: bool = True, 243 skip_headers: bool = False, 244 pretty_headers: bool = False, 245 include_extra_fields: bool = False, 246 **kwargs: typing.Any) -> str: 247 """ 248 Convert a list of base types to a string representation. 249 The returned string will not include a trailing newline. 250 251 The given list may be modified by this call. 252 """ 253 254 values = list(values) 255 256 if (sort): 257 values.sort() 258 259 output = '' 260 261 if (output_format == lms.model.constants.OUTPUT_FORMAT_JSON): 262 output = base_list_to_json(values, 263 include_extra_fields = include_extra_fields, 264 **kwargs) 265 elif (output_format == lms.model.constants.OUTPUT_FORMAT_TABLE): 266 output = base_list_to_table(values, 267 skip_headers = skip_headers, pretty_headers = pretty_headers, 268 include_extra_fields = include_extra_fields, 269 **kwargs) 270 elif (output_format == lms.model.constants.OUTPUT_FORMAT_TEXT): 271 output = base_list_to_text(values, 272 skip_headers = skip_headers, pretty_headers = pretty_headers, 273 include_extra_fields = include_extra_fields, 274 **kwargs) 275 else: 276 raise ValueError(f"Unknown output format: '{output_format}'.") 277 278 return output
Convert a list of base types to a string representation. The returned string will not include a trailing newline.
The given list may be modified by this call.
280def base_list_to_json(values: typing.Sequence[BaseType], 281 indent: int = 4, 282 extract_single_list: bool = False, 283 **kwargs: typing.Any) -> str: 284 """ Convert a list of base types to a JSON string representation. """ 285 286 output_values = [value.as_json_dict(**kwargs) for value in values] 287 if (extract_single_list and (len(output_values) == 1)): 288 output_values = output_values[0] # type: ignore[assignment] 289 290 return str(edq.util.json.dumps(output_values, indent = indent, sort_keys = False))
Convert a list of base types to a JSON string representation.
292def base_list_to_table(values: typing.Sequence[BaseType], 293 skip_headers: bool = False, 294 delim: str = "\t", 295 **kwargs: typing.Any) -> str: 296 """ Convert a list of base types to a table string representation. """ 297 298 rows = [] 299 300 if ((len(values) > 0) and (not skip_headers)): 301 rows.append(values[0].get_headers(**kwargs)) 302 303 for value in values: 304 rows += value.as_table_rows(**kwargs) 305 306 return "\n".join([delim.join(row) for row in rows])
Convert a list of base types to a table string representation.
308def base_list_to_text(values: typing.Sequence[BaseType], 309 **kwargs: typing.Any) -> str: 310 """ Convert a list of base types to a text string representation. """ 311 312 output = [] 313 314 for value in values: 315 rows = value.as_text_rows(**kwargs) 316 output.append("\n".join(rows)) 317 318 return "\n\n".join(output)
Convert a list of base types to a text string representation.