@sw-tsdk/plugin-connector 3.13.1 → 3.13.2-next.3dfd44a

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 (74) hide show
  1. package/README.md +18 -18
  2. package/lib/commands/connector/build.js +168 -44
  3. package/lib/commands/connector/build.js.map +1 -1
  4. package/lib/commands/connector/sign.js +108 -12
  5. package/lib/commands/connector/sign.js.map +1 -1
  6. package/lib/commands/connector/validate.js +110 -10
  7. package/lib/commands/connector/validate.js.map +1 -1
  8. package/lib/commands/migrator/convert.d.ts +3 -0
  9. package/lib/commands/migrator/convert.js +201 -20
  10. package/lib/commands/migrator/convert.js.map +1 -1
  11. package/lib/templates/migrator-runners/plugin_override.txt +76 -4
  12. package/lib/templates/migrator-runners/runner_override.txt +30 -0
  13. package/lib/templates/migrator-runners/script_override.txt +77 -5
  14. package/lib/templates/swimlane/__init__.py +18 -0
  15. package/lib/templates/swimlane/core/__init__.py +0 -0
  16. package/lib/templates/swimlane/core/adapters/__init__.py +10 -0
  17. package/lib/templates/swimlane/core/adapters/app.py +59 -0
  18. package/lib/templates/swimlane/core/adapters/app_revision.py +49 -0
  19. package/lib/templates/swimlane/core/adapters/helper.py +84 -0
  20. package/lib/templates/swimlane/core/adapters/record.py +468 -0
  21. package/lib/templates/swimlane/core/adapters/record_revision.py +43 -0
  22. package/lib/templates/swimlane/core/adapters/report.py +65 -0
  23. package/lib/templates/swimlane/core/adapters/task.py +58 -0
  24. package/lib/templates/swimlane/core/adapters/usergroup.py +183 -0
  25. package/lib/templates/swimlane/core/bulk.py +48 -0
  26. package/lib/templates/swimlane/core/cache.py +165 -0
  27. package/lib/templates/swimlane/core/client.py +466 -0
  28. package/lib/templates/swimlane/core/cursor.py +100 -0
  29. package/lib/templates/swimlane/core/fields/__init__.py +46 -0
  30. package/lib/templates/swimlane/core/fields/attachment.py +82 -0
  31. package/lib/templates/swimlane/core/fields/base/__init__.py +15 -0
  32. package/lib/templates/swimlane/core/fields/base/cursor.py +90 -0
  33. package/lib/templates/swimlane/core/fields/base/field.py +149 -0
  34. package/lib/templates/swimlane/core/fields/base/multiselect.py +116 -0
  35. package/lib/templates/swimlane/core/fields/comment.py +48 -0
  36. package/lib/templates/swimlane/core/fields/datetime.py +112 -0
  37. package/lib/templates/swimlane/core/fields/history.py +28 -0
  38. package/lib/templates/swimlane/core/fields/list.py +266 -0
  39. package/lib/templates/swimlane/core/fields/number.py +38 -0
  40. package/lib/templates/swimlane/core/fields/reference.py +169 -0
  41. package/lib/templates/swimlane/core/fields/text.py +30 -0
  42. package/lib/templates/swimlane/core/fields/tracking.py +10 -0
  43. package/lib/templates/swimlane/core/fields/usergroup.py +137 -0
  44. package/lib/templates/swimlane/core/fields/valueslist.py +70 -0
  45. package/lib/templates/swimlane/core/resolver.py +46 -0
  46. package/lib/templates/swimlane/core/resources/__init__.py +0 -0
  47. package/lib/templates/swimlane/core/resources/app.py +136 -0
  48. package/lib/templates/swimlane/core/resources/app_revision.py +43 -0
  49. package/lib/templates/swimlane/core/resources/attachment.py +64 -0
  50. package/lib/templates/swimlane/core/resources/base.py +55 -0
  51. package/lib/templates/swimlane/core/resources/comment.py +33 -0
  52. package/lib/templates/swimlane/core/resources/record.py +499 -0
  53. package/lib/templates/swimlane/core/resources/record_revision.py +44 -0
  54. package/lib/templates/swimlane/core/resources/report.py +259 -0
  55. package/lib/templates/swimlane/core/resources/revision_base.py +69 -0
  56. package/lib/templates/swimlane/core/resources/task.py +16 -0
  57. package/lib/templates/swimlane/core/resources/usergroup.py +166 -0
  58. package/lib/templates/swimlane/core/search.py +31 -0
  59. package/lib/templates/swimlane/core/wrappedsession.py +12 -0
  60. package/lib/templates/swimlane/exceptions.py +191 -0
  61. package/lib/templates/swimlane/utils/__init__.py +132 -0
  62. package/lib/templates/swimlane/utils/date_validator.py +4 -0
  63. package/lib/templates/swimlane/utils/list_validator.py +7 -0
  64. package/lib/templates/swimlane/utils/str_validator.py +10 -0
  65. package/lib/templates/swimlane/utils/version.py +101 -0
  66. package/lib/transformers/base-transformer.js +61 -14
  67. package/lib/transformers/base-transformer.js.map +1 -1
  68. package/lib/transformers/connector-generator.d.ts +104 -2
  69. package/lib/transformers/connector-generator.js +1234 -51
  70. package/lib/transformers/connector-generator.js.map +1 -1
  71. package/lib/types/migrator-types.d.ts +22 -0
  72. package/lib/types/migrator-types.js.map +1 -1
  73. package/oclif.manifest.json +1 -1
  74. package/package.json +6 -6
@@ -0,0 +1,499 @@
1
+ import copy
2
+ from functools import total_ordering
3
+ import time
4
+ import pendulum
5
+ import six
6
+ from swimlane.core.resources.base import APIResource
7
+ from swimlane.core.resources.usergroup import UserGroup, User
8
+ from swimlane.exceptions import SwimlaneException, UnknownField, ValidationError
9
+ import swimlane.core.adapters.task # avoid circular reference
10
+ import swimlane.core.adapters.helper # avoid circular reference
11
+
12
+
13
+
14
+ @total_ordering
15
+ class Record(APIResource):
16
+ """A single Swimlane Record instance
17
+
18
+ Attributes:
19
+ id (str): Full Record ID
20
+ tracking_id (str): Record tracking ID
21
+ created (pendulum.DateTime): Pendulum datetime for Record created date
22
+ modified (pendulum.DateTime): Pendulum datetime for Record last modified date
23
+ is_new (bool): True if Record does not yet exist on server. Other values may be temporarily None if True
24
+ app (App): App instance that Record belongs to
25
+ """
26
+
27
+ _type = 'Core.Models.Record.Record, Core'
28
+
29
+ def __init__(self, app, raw, is_new=None):
30
+ super(Record, self).__init__(app._swimlane, raw)
31
+
32
+ self.__app = app
33
+
34
+ self.is_new = self._raw.get('isNew', False)
35
+ if is_new is not None:
36
+ self.is_new = is_new
37
+ # Protect against creation from generic raw data not yet containing server-generated values
38
+ if self.is_new:
39
+ self.id = self.tracking_id = self.created = self.modified = None
40
+ else:
41
+ record_app_id = raw['applicationId']
42
+ if record_app_id != app.id:
43
+ raise ValueError('Record applicationId "{}" does not match source app id "{}"'.format(
44
+ record_app_id,
45
+ app.id
46
+ ))
47
+
48
+ self.id = self._raw['id']
49
+
50
+ # Combine app acronym + trackingId instead of using trackingFull raw
51
+ # for guaranteed value (not available through report results)
52
+ self.tracking_id = '-'.join([
53
+ self.app.acronym,
54
+ str(int(self._raw['trackingId']))
55
+ ])
56
+
57
+ self.created = pendulum.parse(self._raw['createdDate'])
58
+ self.modified = pendulum.parse(self._raw['modifiedDate'])
59
+
60
+ self.__allowed = []
61
+
62
+ self._fields = {}
63
+ self.__premap_fields()
64
+
65
+ # Get trackingFull if available
66
+ if app.tracking_id in self._raw['values']:
67
+ self._raw['trackingFull'] = self._raw['values'].get(app.tracking_id)
68
+
69
+ self.__existing_values = {k: self.get_field(k).get_batch_representation() for (k, v) in self}
70
+ self._comments_modified = False
71
+
72
+ self.locked = False
73
+ self.locking_user = None
74
+ self.locked_date = None
75
+
76
+ # avoid circular reference
77
+ from swimlane.core.adapters import RecordRevisionAdapter
78
+ self.revisions = RecordRevisionAdapter(app, self)
79
+
80
+ @property
81
+ def app(self):
82
+ return self.__app
83
+
84
+ def __str__(self):
85
+ if self.is_new:
86
+ return '{} - New'.format(self.app.acronym)
87
+
88
+ return str(self.tracking_id)
89
+
90
+ def __setitem__(self, field_name, value):
91
+ keys = dir(value)
92
+ if '_elements' in keys:
93
+ value = value._elements
94
+ self.get_field(field_name).set_python(value)
95
+
96
+ def __getitem__(self, field_name):
97
+ return self.get_field(field_name).get_item()
98
+
99
+ def __delitem__(self, field_name):
100
+ self[field_name] = None
101
+
102
+ def __iter__(self):
103
+ for field_name, field in six.iteritems(self._fields):
104
+ yield field_name, field.get_python()
105
+
106
+ def __hash__(self):
107
+ return hash((self.id, self.app))
108
+
109
+ def __lt__(self, other):
110
+ if not isinstance(other, self.__class__):
111
+ raise TypeError('Comparisons not supported between instances of "{}" and "{}"'.format(
112
+ other.__class__.__name__,
113
+ self.__class__.__name__
114
+ ))
115
+
116
+ tracking_number_self = int(self.tracking_id.split('-')[1])
117
+ tracking_number_other = int(other.tracking_id.split('-')[1])
118
+
119
+ return (self.app.name, tracking_number_self) < (other.app.name, tracking_number_other)
120
+
121
+ def __premap_fields(self):
122
+ """Build field instances using field definitions in app manifest
123
+
124
+ Map raw record field data into appropriate field instances with their correct respective types
125
+ """
126
+ # Circular imports
127
+ from swimlane.core.fields import resolve_field_class
128
+
129
+ for field_definition in self.app._raw['fields']:
130
+ field_class = resolve_field_class(field_definition)
131
+
132
+ field_instance = field_class(field_definition['name'], self)
133
+ value = self._raw['values'].get(field_instance.id)
134
+ field_instance.set_swimlane(value)
135
+
136
+ self._fields[field_instance.name] = field_instance
137
+
138
+ def get_cache_index_keys(self):
139
+ """Return values available for retrieving records, but only for already existing records"""
140
+ if not (self.id and self.tracking_id):
141
+ raise NotImplementedError
142
+
143
+ return {
144
+ 'id': self.id,
145
+ 'tracking_id': self.tracking_id
146
+ }
147
+
148
+ def get_field(self, field_name):
149
+ """Get field instance used to get, set, and serialize internal field value
150
+
151
+ Args:
152
+ field_name (str): Field name or key to retrieve
153
+
154
+ Returns:
155
+ Field: Requested field instance
156
+
157
+ Raises:
158
+ UnknownField: Raised if `field_name` not found in parent App
159
+ """
160
+ try:
161
+ return self._fields[self.app.resolve_field_name(field_name)]
162
+ except KeyError:
163
+ raise UnknownField(self.app, field_name, self._fields.keys())
164
+
165
+ def validate(self):
166
+ """Explicitly validate field data
167
+
168
+ Notes:
169
+ Called automatically during save call before sending data to server
170
+
171
+ Raises:
172
+ ValidationError: If any fields fail validation
173
+ """
174
+ for field in (_field for _field in six.itervalues(self._fields) if _field.required):
175
+ if field.get_swimlane() is None:
176
+ raise ValidationError(
177
+ self, 'Required field "{}" is not set'.format(field.name))
178
+
179
+ def __request_and_reinitialize(self, method, endpoint, data):
180
+ response = self._swimlane.request(
181
+ method,
182
+ endpoint,
183
+ json=data
184
+ )
185
+
186
+ # Reinitialize record with new raw content returned from server to update any calculated fields
187
+ self.__init__(self.app, response.json(), is_new=False)
188
+
189
+ # Manually cache self after save to keep cache updated with latest data
190
+ self._swimlane.resources_cache.cache(self)
191
+
192
+ def save(self):
193
+ """Persist record changes on Swimlane server
194
+
195
+ Updates internal raw data with response content from server to guarantee calculated field values match values on
196
+ server
197
+
198
+ Raises:
199
+ ValidationError: If any fields fail validation
200
+ """
201
+
202
+ if self.is_new:
203
+ method = 'post'
204
+ else:
205
+ method = 'put'
206
+
207
+ # Pop off fields with None value to allow for saving empty fields
208
+ copy_raw = copy.copy(self._raw)
209
+ values_dict = {}
210
+ for key, value in six.iteritems(copy_raw['values']):
211
+ if value is not None:
212
+ values_dict[key] = value
213
+ copy_raw['values'] = values_dict
214
+
215
+ self.validate()
216
+
217
+ self.__request_and_reinitialize(
218
+ method,
219
+ 'app/{}/record'.format(self.app.id),
220
+ copy_raw
221
+ )
222
+
223
+ def patch(self):
224
+ """Patch record on Swimlane server
225
+
226
+ Raises
227
+ ValueError: If record.is_new, or if comments or attachments are attempted to be patched
228
+ """
229
+ if self.is_new:
230
+ raise ValueError('Cannot patch a new Record')
231
+ elif self._comments_modified:
232
+ raise ValueError('Can not patch with added comments')
233
+
234
+ copy_raw = copy.copy(self._raw)
235
+
236
+ pending_values = {k: self.get_field(k).get_batch_representation() for (k, v) in self}
237
+ patch_values = {
238
+ self.get_field(k).id: pending_values[k] for k in set(pending_values) & set(self.__existing_values)
239
+ if pending_values[k] != self.__existing_values[k]
240
+ }
241
+
242
+ for field_id, value in six.iteritems(patch_values):
243
+ #
244
+ if self.app.get_field_definition_by_id(field_id)['fieldType'] == 'attachment':
245
+ raise ValueError('Can not patch new attachments')
246
+ # Use None for empty arrays to ensure field is removed from Record on PATCH
247
+ if not value and value != 0:
248
+ patch_values[field_id] = None
249
+
250
+ # $type needed here for dotnet to deserialize correctly
251
+ patch_values['$type'] = self._raw['values']['$type']
252
+ copy_raw['values'] = patch_values
253
+
254
+ self.validate()
255
+
256
+ self.__request_and_reinitialize(
257
+ 'patch',
258
+ 'app/{}/record/{}'.format(self.app.id, self.id),
259
+ copy_raw
260
+ )
261
+
262
+ def delete(self):
263
+ """Delete record from Swimlane server
264
+
265
+ .. versionadded:: 2.16.1
266
+
267
+ Resets to new state, but leaves field data as-is. Saving a deleted record will create a new Swimlane record
268
+
269
+ Raises
270
+ ValueError: If record.is_new
271
+ """
272
+ if self.is_new:
273
+ raise ValueError('Cannot delete a new Record')
274
+
275
+ self._swimlane.request(
276
+ 'delete',
277
+ 'app/{}/record/{}'.format(self.app.id, self.id)
278
+ )
279
+
280
+ del self._swimlane.resources_cache[self]
281
+
282
+ # Modify current raw values indicating an unsaved record but persisting field data
283
+ raw = copy.deepcopy(self._raw)
284
+ raw['id'] = None
285
+ raw['isNew'] = True
286
+
287
+ self.__init__(self.app, raw)
288
+
289
+ def for_json(self, *field_names):
290
+ """Returns json.dump()-compatible dict representation of the record
291
+
292
+ .. versionadded:: 4.1
293
+
294
+ Useful for resolving any Cursor, datetime/Pendulum, etc. field values to useful formats outside of Python
295
+
296
+ Args:
297
+ *field_names (str): Optional subset of field(s) to include in returned dict. Defaults to all fields
298
+
299
+ Raises:
300
+ UnknownField: Raised if any of `field_names` not found in parent App
301
+
302
+ Returns:
303
+ dict: field names -> JSON compatible field values
304
+ """
305
+ field_names = field_names or self._fields.keys()
306
+
307
+ return {field_name: self.get_field(field_name).for_json() for field_name in field_names}
308
+
309
+ @property
310
+ def restrictions(self):
311
+ """Returns cached set of retrieved UserGroups in the record's list of allowed accounts"""
312
+ return [UserGroup(self._swimlane, raw) for raw in self._raw['allowed']]
313
+
314
+ def add_restriction(self, *usergroups):
315
+ """Add UserGroup(s) to list of accounts with access to record
316
+
317
+ .. versionadded:: 2.16.1
318
+
319
+ UserGroups already in the restricted list can be added multiple times and duplicates will be ignored
320
+
321
+ Notes:
322
+
323
+ Args:
324
+ *usergroups (UserGroup): 1 or more Swimlane UserGroup(s) to add to restriction list
325
+
326
+ Raises:
327
+ TypeError: If 0 UserGroups provided or provided a non-UserGroup instance
328
+ """
329
+ if not usergroups:
330
+ raise TypeError(
331
+ 'Must provide at least one UserGroup for restriction')
332
+
333
+ allowed = copy.copy(self._raw.get('allowed', []))
334
+
335
+ for usergroup in usergroups:
336
+ if not isinstance(usergroup, UserGroup):
337
+ raise TypeError(
338
+ 'Expected UserGroup, received "{}" instead'.format(usergroup))
339
+
340
+ selection = usergroup.as_usergroup_selection()
341
+ if selection not in allowed:
342
+ allowed.append(selection)
343
+
344
+ self.validate()
345
+ self._swimlane.request(
346
+ 'put',
347
+ 'app/{}/record/{}/restrict'.format(self.app.id, self.id),
348
+ json=allowed
349
+ )
350
+
351
+ self._raw['allowed'] = allowed
352
+
353
+ def remove_restriction(self, *usergroups):
354
+ """Remove UserGroup(s) from list of accounts with access to record
355
+
356
+ .. versionadded:: 2.16.1
357
+
358
+ Notes:
359
+
360
+ Warnings:
361
+ Providing no UserGroups will clear the restriction list, opening access to ALL accounts
362
+
363
+ Args:
364
+ *usergroups (UserGroup): 0 or more Swimlane UserGroup(s) to remove from restriction list
365
+
366
+ Raises:
367
+ TypeError: If provided a non-UserGroup instance
368
+ ValueError: If provided UserGroup not in current restriction list
369
+ """
370
+ if usergroups:
371
+ allowed = copy.copy(self._raw.get('allowed', []))
372
+
373
+ for usergroup in usergroups:
374
+ if not isinstance(usergroup, UserGroup):
375
+ raise TypeError(
376
+ 'Expected UserGroup, received "{}" instead'.format(usergroup))
377
+ try:
378
+ allowed.remove(usergroup.as_usergroup_selection())
379
+ except ValueError:
380
+ raise ValueError(
381
+ 'UserGroup "{}" not in record "{}" restriction list'.format(usergroup, self))
382
+ else:
383
+ allowed = []
384
+
385
+ self.validate()
386
+ self._swimlane.request(
387
+ 'put',
388
+ 'app/{}/record/{}/restrict'.format(self.app.id, self.id),
389
+ json=allowed
390
+ )
391
+
392
+ self._raw['allowed'] = allowed
393
+
394
+ def lock(self):
395
+ """
396
+ Lock the record to the Current User.
397
+
398
+
399
+ Notes:
400
+
401
+ Warnings:
402
+
403
+ Args:
404
+
405
+ """
406
+ self.validate()
407
+ response = self._swimlane.request(
408
+ 'post',
409
+ 'app/{}/record/{}/lock'.format(
410
+ self.app.id, self.id)
411
+ ).json()
412
+ self.locked = True
413
+ self.locking_user = User(self._swimlane, response['lockingUser'])
414
+ self.locked_date = response['lockedDate']
415
+
416
+ def unlock(self):
417
+ """
418
+ Unlock the record.
419
+
420
+
421
+ Notes:
422
+
423
+ Warnings:
424
+
425
+ Args:
426
+
427
+ """
428
+ self.validate()
429
+ self._swimlane.request(
430
+ 'post',
431
+ 'app/{}/record/{}/unlock'.format(
432
+ self.app.id, self.id)
433
+ ).json()
434
+ self.locked = False
435
+ self.locking_user = None
436
+ self.locked_date = None
437
+
438
+ def execute_task(self, task_name, timeout=int(20)):
439
+ job_info = swimlane.core.adapters.task.TaskAdapter(self.app._swimlane).execute(task_name, self._raw)
440
+ '''timeout_start = pendulum.now()
441
+ while pendulum.now() < timeout_start.add(seconds=timeout):
442
+ status = self.app._swimlane.helpers.check_bulk_job_status(job_info.text)
443
+ if len(status):
444
+ for item in status:
445
+ if item.get('status') == 'completed':
446
+ self.__request_and_reinitialize(
447
+ 'get', '/app/{appId}/record/{id}'.format(appId=self.app.id, id=self.id), None)
448
+ timeout = 0
449
+ if item.get('status') == 'failed':
450
+ raise SwimlaneException('Task failed: {}'.format(item.get('message')))
451
+ time.sleep(1)
452
+ '''
453
+ '''if self._swimlane._execute_task_webhook_url:
454
+ self._swimlane._session.post(self._swimlane._execute_task_webhook_url, json={'task_name': task_name, 'record': self.for_json()})
455
+ else:
456
+ raise SwimlaneException('Task webhook URL is not set')'''
457
+
458
+ def record_factory(app, fields=None):
459
+ """Return a temporary Record instance to be used for field validation and value parsing
460
+
461
+ Args:
462
+ app (App): Target App to create a transient Record instance for
463
+ fields (dict): Optional dict of fields and values to set on new Record instance before returning
464
+
465
+ Returns:
466
+ Record: Unsaved Record instance to be used for validation, creation, etc.
467
+ """
468
+ # pylint: disable=line-too-long
469
+ record = Record(app, {
470
+ '$type': Record._type,
471
+ 'isNew': True,
472
+ 'applicationId': app.id,
473
+ 'comments': {
474
+ '$type': 'System.Collections.Generic.Dictionary`2[[System.String, mscorlib],[System.Collections.Generic.List`1[[Core.Models.Record.Comments, Core]], mscorlib]], mscorlib'
475
+ },
476
+ 'values': {
477
+ '$type': 'System.Collections.Generic.Dictionary`2[[System.String, mscorlib],[System.Object, mscorlib]], mscorlib'
478
+ }
479
+ })
480
+
481
+ fields = fields or {}
482
+
483
+ # Apply Default Values
484
+ for name, value in six.iteritems(app._defaults):
485
+ record[name] = value
486
+
487
+ # Apply Provided Field Values
488
+ for name, value in six.iteritems(fields):
489
+ record[name] = value
490
+
491
+ # Pop off fields with None value to allow for saving empty fields
492
+ copy_raw = copy.copy(record._raw)
493
+ values_dict = {}
494
+ for key, value in six.iteritems(copy_raw['values']):
495
+ if value is not None:
496
+ values_dict[key] = value
497
+ record._raw['values'] = values_dict
498
+
499
+ return record
@@ -0,0 +1,44 @@
1
+ from swimlane.core.resources.record import Record
2
+ from swimlane.core.resources.revision_base import RevisionBase
3
+
4
+
5
+ class RecordRevision(RevisionBase):
6
+ """
7
+ Encapsulates a single revision returned from a History lookup.
8
+
9
+ Attributes:
10
+ app_revision_number: The app revision number this record revision was created using.
11
+
12
+ Properties:
13
+ app_version: Returns an App corresponding to the app_revision_number of this record revision.
14
+ version: Returns a Record corresponding to the app_version and data contained in this record revision.
15
+ """
16
+ def __init__(self, app, raw):
17
+ super(RecordRevision, self).__init__(app._swimlane, raw)
18
+
19
+ self.__app_version = None
20
+ self._app = app
21
+
22
+ self._app_revision_number = self._raw_version['applicationRevision']
23
+
24
+ @property
25
+ def app_version(self):
26
+ """The app revision corresponding to this record revision. Lazy loaded"""
27
+ if not self.__app_version:
28
+ self.__app_version = self._app.revisions.get(self.app_revision_number).version
29
+ return self.__app_version
30
+
31
+ @property
32
+ def version(self):
33
+ """The record contained in this record revision. Lazy loaded. Overridden from base class."""
34
+ if not self._version:
35
+ self._version = Record(self.app_version, self._raw_version)
36
+ return self._version
37
+
38
+ @property
39
+ def app_revision_number(self):
40
+ return self._app_revision_number
41
+
42
+ @app_revision_number.setter
43
+ def app_revision_number(self, value):
44
+ raise AttributeError("can't set attribute")