В прошлом уроке property решил главную проблему классических геттеров и сеттеров: работа с защищёнными данными стала выглядеть так же просто, как обращение к обычному атрибуту, при этом не теряя контроля над чтением и записью. Но у property есть практическое ограничение, которое становится заметно, как только в классе появляется не один, а несколько атрибутов, нуждающихся в одинаковой проверке.
Рассмотрим класс точки в трёхмерном пространстве, где каждая координата обязана быть целым числом:
class Point3D:
@classmethod
def verify_coord(cls, coord):
if type(coord) != int:
raise TypeError("Координата должна быть целым числом")
@property
def x(self):
return self._x
@x.setter
def x(self, coord):
self.verify_coord(coord)
self._x = coord
@property
def y(self):
return self._y
@y.setter
def y(self, coord):
self.verify_coord(coord)
self._y = coord
@property
def z(self):
return self._z
@z.setter
def z(self, coord):
self.verify_coord(coord)
self._z = coord
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = zПроверка verify_coord действительно вынесена в отдельный метод и не дублируется по содержанию. Но сама структура — пара @property/@setter, обращение к своему собственному приватному атрибуту, вызов проверки — повторена три раза почти дословно, отличаясь только именем атрибута (x, y, z вместо _x, _y, _z). Если бы полей было не три, а пятнадцать или двадцать, объём практически идентичного, механически копируемого кода стал бы серьёзной проблемой сопровождения: любое изменение в логике проверки пришлось бы вручную вносить в каждое из пятнадцати мест.
В этом уроке разбирается более фундаментальный механизм, лежащий в основе самого property, — дескрипторы. Они позволяют написать логику контроля доступа один раз, в виде отдельного класса, и затем переиспользовать её для любого количества атрибутов и даже разных классов, не копируя код заново.
Дескриптор — это класс, который управляет доступом к атрибуту другого класса, реализуя один или несколько специальных методов: __get__ — для чтения, __set__ — для записи, __delete__ — для удаления. Если объект такого класса присваивается в качестве атрибута класса, Python начинает обращаться к нему не напрямую, а через эти методы всякий раз, когда происходит обращение к соответствующему имени у экземпляра.
Различают два вида дескрипторов. Дескриптор без данных (non-data descriptor) реализует только __get__ — он способен перехватывать чтение, но не запись. Дескриптор с данными (data descriptor) реализует и __get__, и __set__ (а возможно, и __delete__) — он способен полностью контролировать все обращения к атрибуту. Разница между ними не декоративная: как будет показано дальше в этом уроке, от неё напрямую зависит приоритет дескриптора по отношению к обычным атрибутам объекта.
Создадим дескриптор, отвечающий за хранение целого числа:
class Integer:
def __set_name__(self, owner, name):
self.name = "_" + name
def __get__(self, instance, owner):
return instance.__dict__[self.name]
def __set__(self, instance, value):
instance.__dict__[self.name] = valueПрименим его к классу Point3D:
class Point3D:
x = Integer()
y = Integer()
z = Integer()
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = zpt = Point3D(1, 2, 3)
print(pt.__dict__) # {'_x': 1, '_y': 2, '_z': 3}Обратите внимание на итоговую структуру класса: теперь одна и та же логика (класс Integer) обслуживает сразу три координаты, и добавление четвёртой координаты потребовало бы одной строки (w = Integer()), а не копирования блока из шести строк, как это было бы с property. Чтобы понять, как это работает изнутри, разберём происходящее по шагам.
Когда Python выполняет тело класса Point3D и доходит до строки x = Integer(), происходит следующее: сначала создаётся объект класса Integer — обычный вызов конструктора, ничем не отличающийся от создания любого другого объекта. Этот объект сохраняется в пространстве имён класса Point3D под именем x, то есть Point3D.__dict__['x'] теперь содержит этот объект Integer. И сразу вслед за этим Python вызывает специальный метод __set_name__ у только что созданного объекта, автоматически передавая ему класс, в котором дескриптор был объявлен (owner), и то имя, под которым он был сохранён (name).
def __set_name__(self, owner, name):
self.name = "_" + nameЗдесь owner равен Point3D, а name равен 'x'. Метод формирует и запоминает имя, под которым фактическое значение будет храниться внутри конкретного объекта: '_x'. Точно такой же вызов происходит для y и z, каждый раз с соответствующим именем. __set_name__ вызывается Python-ом автоматически, единственный раз — в момент создания класса, а не в момент создания какого-либо объекта, — и это тот механизм, который избавляет от необходимости писать self.name = "_x" вручную для каждого дескриптора отдельно: имя вычисляется автоматически, исходя из того, под каким именем сам дескриптор был объявлен в классе.
Выполним pt = Point3D(1, 2, 3). Внутри __init__ выполняется строка self.x = x. На первый взгляд это обычное присваивание через объект, разобранное ещё во втором уроке, — но на этот раз оно ведёт себя иначе. Python обнаруживает, что x в классе Point3D — это дескриптор с данными (у Integer определён __set__), и вместо обычного создания записи x в pt.__dict__ вызывает:
Integer.__set__(дескриптор_x, instance=pt, value=1)Параметры этого метода стоит разобрать явно: self — это сам объект дескриптора (тот, что хранится в Point3D.__dict__['x']), instance — это объект, с которым сейчас работает код (pt), а value — присваиваемое значение (1). Тело метода выполняет:
instance.__dict__[self.name] = valueто есть фактически pt.__dict__['_x'] = 1. Обратите внимание: значение сохраняется не под именем x, а под именем _x, вычисленным ранее в __set_name__. Именно поэтому pt.__dict__, если на него взглянуть напрямую, не содержит ключа x вовсе — там присутствуют только _x, _y, _z.
Выполним print(pt.x). Python снова обнаруживает, что x — дескриптор, и на этот раз вызывает:
Integer.__get__(дескриптор_x, instance=pt, owner=Point3D)instance — это pt, owner — класс Point3D. Здесь стоит обратить внимание на не самый очевидный случай: если обратиться к дескриптору не через объект, а напрямую через класс — Point3D.x — то instance окажется равен None, а owner по-прежнему будет содержать Point3D. Это позволяет дескриптору вести себя по-разному в зависимости от того, вызван ли он для конкретного объекта или для класса в целом, хотя в приведённой реализации Integer эта возможность не используется.
Тело __get__ выполняет:
return instance.__dict__[self.name]то есть возвращает pt.__dict__['_x'], а значит, 1.
Всю эту последовательность удобно представить в виде короткой схемы:
Запись: pt.x = 10 → Integer.__set__(pt, 10) → pt.__dict__['_x'] = 10
Чтение: pt.x → Integer.__get__(pt, Point3D) → return pt.__dict__['_x']
Ровно то же место, где property выполнял бы проверку в своём сеттере, дескриптор использует в собственном __set__:
class Integer:
@classmethod
def verify_coord(cls, value):
if type(value) != int:
raise TypeError("Координата должна быть целым числом")
def __set_name__(self, owner, name):
self.name = "_" + name
def __get__(self, instance, owner):
return getattr(instance, self.name)
def __set__(self, instance, value):
self.verify_coord(value)
setattr(instance, self.name, value)pt = Point3D(1, 2, 3)
pt.x = 10 # проходит проверку
pt.y = "test" # TypeError: Координата должна быть целым числомЗдесь стоит объяснить замену instance.__dict__[self.name] на getattr(instance, self.name) и setattr(instance, self.name, value). Обе формы записи в данном конкретном случае дают одинаковый результат, поскольку self.name (например, '_x') — это обычное имя, не связанное ни с каким другим дескриптором, а значит, getattr/setattr в итоге всё равно обращаются к __dict__ объекта напрямую, никакого дополнительного дескриптора по пути не встречая. getattr/setattr считаются более аккуратной формой записи, потому что они уважают весь обычный протокол поиска и присваивания атрибутов, включая случаи, не предусмотренные этой простой реализацией (например, если бы у класса Point3D был собственный __setattr__, что будет разбираться в одном из следующих уроков). Прямое обращение к instance.__dict__[...], напротив, полностью обходит любую другую логику, которая могла бы быть связана с обычным присваиванием атрибута, и потому в более сложных сценариях может привести к неожиданным результатам.
Здесь же стоит отметить важную ловушку, которой в этом коде удаётся избежать только благодаря __set_name__: если бы self.name хранил не '_x', а 'x' — то есть точно такое же имя, каким назван сам дескриптор в классе, — вызов setattr(instance, 'x', value) внутри __set__ заново обратился бы к тому же самому дескриптору x, что вызвало бы повторный вызов __set__, который снова обратился бы к setattr(instance, 'x', ...), и так далее до бесконечности, пока программа не завершится ошибкой переполнения стека вызовов. Именно поэтому __set_name__ вычисляет для реального хранения значение имя, отличное от имени самого дескриптора (_x вместо x), — без этого различия дескриптор был бы попросту неработоспособен.
Создадим дескриптор, реализующий только __get__, — non-data descriptor:
class ReadIntX:
def __set_name__(self, owner, name):
self.name = "_x"
def __get__(self, instance, owner):
return getattr(instance, self.name)
class Point3D:
x = Integer()
y = Integer()
z = Integer()
xr = ReadIntX()
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = zpt = Point3D(1, 2, 3)
print(pt.xr) # 1 — чтение работает, __get__ вызывается
pt.xr = 100
print(pt.xr) # 100Второе присваивание не вызвало никакой ошибки, но результат неожиданный: значение изменилось, хотя у ReadIntX вообще нет метода __set__. Разгадка в том, что произошло на самом деле: раз у дескриптора ReadIntX нет __set__, Python не имеет способа перехватить операцию записи через него, и присваивание pt.xr = 100 выполняется как совершенно обычное присваивание через объект — создаётся (точнее, теперь уже существует) отдельная запись xr непосредственно в pt.__dict__. С этого момента при чтении pt.xr Python сначала находит xr прямо в словаре объекта — согласно правилу поиска атрибутов, разобранному ещё во втором уроке, — и это обычное значение из __dict__ объекта перекрывает дескриптор класса, поскольку non-data descriptor не обладает приоритетом над содержимым __dict__ экземпляра.
Теперь добавим __set__ в тот же дескриптор — превратив его в data descriptor:
class ReadIntX:
def __set_name__(self, owner, name):
self.name = "_x"
def __get__(self, instance, owner):
return getattr(instance, self.name)
def __set__(self, instance, value):
setattr(instance, self.name, value)pt = Point3D(1, 2, 3)
pt.__dict__['xr'] = 100
print(pt.xr) # 1На этот раз даже прямое, «в обход» изменение словаря объекта (pt.__dict__['xr'] = 100) не повлияло на то, что вернёт pt.xr. Причина в порядке поиска, которым руководствуется Python: если в классе объявлен data descriptor (обладающий и __get__, и __set__), он проверяется прежде, чем содержимое __dict__ самого объекта, и потому обращение pt.xr вызывает ReadIntX.__get__, полностью игнорируя то, что лежит в pt.__dict__['xr']. Non-data descriptor, напротив, проверяется только тогда, когда в __dict__ объекта не нашлось одноимённой записи — то есть уступает объекту приоритет.
Этот же самый принцип, отметим, объясняет и поведение property, разобранное в прошлом уроке: property, реализующий __get__, __set__ и __delete__, — это в точности data descriptor, и именно поэтому он всегда обладает приоритетом над содержимым __dict__ объекта, даже если туда что-то записать напрямую в обход свойства.
До этого момента мы говорили о дескрипторе как о механизме, который перехватывает чтение и запись атрибута. Теперь важно связать это с понятием инкапсуляции.
Дескриптор сам по себе не делает атрибут приватным. Его задача заключается в том, чтобы инкапсулировать логику доступа к данным: внешний код работает с обычным атрибутом, например pt.x, а правила чтения, записи, проверки и хранения значения находятся внутри дескриптора.
В нашем примере:
pt.x = 10не приводит к обычной записи x в pt.__dict__. Вместо этого вызывается Integer.__set__(), который проверяет значение и сохраняет его под другим именем:
pt.__dict__['_x'] = 10При чтении происходит обратная операция:
pt.xвызывает Integer.__get__(), который получает значение из внутреннего хранилища _x.
Таким образом, внешний код не обязан знать, где и каким образом фактически хранится значение. Он работает с x, а дескриптор управляет тем, что происходит внутри. В этом и заключается инкапсуляция логики доступа.
При этом _x — это не специальный механизм Python для сокрытия данных. Одно ведущее подчёркивание является соглашением: оно сообщает программистам, что атрибут считается внутренним и не предназначен для обычного использования извне.
Отдельно стоит вспомнить имя с двумя ведущими подчёркиваниями:
class Point:
def __init__(self, x):
self.__x = xЗдесь Python применяет name mangling и фактически сохраняет значение под именем, содержащим имя класса:
point.__dict__
# {'_Point__x': 10}Однако важно понимать, что name mangling не является частью механизма дескрипторов. Если имя формируется динамически и передаётся в getattr() или setattr() как строка, Python не выполняет это преобразование автоматически:
setattr(instance, "__x", 10)В этом случае в __dict__ появится именно ключ __x, а не _Point__x:
instance.__dict__
# {'__x': 10}Поэтому дескриптор, если ему требуется использовать имя по аналогии с __x, должен сформировать его самостоятельно. Например:
class PrivateInteger:
def __set_name__(self, owner, name):
self.name = f"_{owner.__name__}__{name}"
def __get__(self, instance, owner):
return getattr(instance, self.name)
def __set__(self, instance, value):
if type(value) != int:
raise TypeError("Значение должно быть целым числом")
setattr(instance, self.name, value)Теперь можно использовать этот дескриптор как обычный атрибут:
class Point3D:
x = PrivateInteger()
def __init__(self, x):
self.x = x
pt = Point3D(10)
print(pt.__dict__)
# {'_Point3D__x': 10}Здесь важно различать два механизма. В обычной записи self.__x имя преобразовывается Python автоматически. В нашем дескрипторе строка "_Point3D__x" сформирована вручную внутри __set_name__.
На практике для дескриптора обычно достаточно обычного _x, как в классе Integer. Нам важно лишь отделить имя, через которое работает внешний код (x), от имени внутреннего хранения (_x). Если же дескриптор используется в более сложной библиотеке и необходимо избежать конфликтов имён между классами или наследниками, он может самостоятельно формировать более специфичное внутреннее имя.
Таким образом, дескриптор предоставляет механизм контроля доступа, а внутреннее имя хранения и name mangling решают отдельную задачу именования. Их можно использовать вместе, но это разные механизмы Python.
Стоит явно проговорить связь, которая до этого момента оставалась неявной: декоратор @property — это, по сути, удобный способ создать data descriptor, не объявляя для этого отдельный класс вручную. Когда вы пишете:
class Person:
@property
def old(self):
return self.__old
@old.setter
def old(self, value):
self.__old = valueинтерпретатор за кулисами создаёт объект встроенного класса property, у которого реализованы и __get__, и __set__, и который внутри себя хранит ссылки на переданные ему функции-геттер и функции-сеттер, вызывая нужную из них при каждом обращении. Иначе говоря, property — это готовый, встроенный в язык дескриптор общего назначения, рассчитанный на работу с одним конкретным атрибутом одного конкретного класса. Дескрипторы, написанные вручную, — тот же самый механизм, но выведенный на уровень более общего инструмента, который можно параметризовать (как в примере с Integer, одинаково пригодным для x, y и z) и переиспользовать между разными классами.
Несмотря на всю мощь этого механизма, в повседневном коде прикладных программ дескрипторы пишут вручную нечасто — гораздо чаще для тех же задач используют property или обычные атрибуты. Причина в балансе выгоды и сложности: дескриптор требует отдельного класса, понимания жизненного цикла __set_name__/__get__/__set__, понимания разницы между data и non-data дескрипторами и того, как эта разница сказывается на приоритете доступа. Всё это заметно сложнее для чтения и отладки, чем обычный property, и оправдывает себя только тогда, когда одну и ту же логику контроля доступа действительно предстоит применить к достаточно большому числу атрибутов или классов, чтобы избавление от дублирования перевесило возросшую сложность.
Практическое применение дескрипторов в основном сосредоточено в библиотеках и фреймворках, а не в прикладном коде конечных приложений — и понимание этого объясняет, почему сам механизм тем не менее исключительно важен, даже если вы никогда не напишете собственный дескриптор в обычном учебном или рабочем проекте.
Самый заметный пример — ORM-библиотеки, такие как Django ORM или SQLAlchemy, где поля модели объявляются подобным образом:
class User(Model):
name = StringField()
age = IntegerField()Здесь StringField и IntegerField — это дескрипторы, каждый из которых валидирует присваиваемое значение, отвечает за его хранение и, в конечном счёте, за отображение этого поля на столбец таблицы в базе данных. Одна и та же логика поля переиспользуется для сотен различных моделей во всём проекте — ровно тот сценарий, где выгода от дескриптора многократно окупает его сложность.
Второй пример — переиспользуемая валидация в собственном прикладном коде, когда сразу несколько полей нуждаются в одинаковой проверке:
class Product:
price = PositiveNumber()
weight = PositiveNumber()
quantity = PositiveNumber()Один класс PositiveNumber, содержащий логику «значение должно быть положительным числом», обслуживает сразу три поля без какого-либо копирования кода — именно тот случай, с которого этот урок начинался applied к классу Point3D.
Третий и четвёртый примеры — ленивая загрузка (lazy loading) и кеширование, где __get__ вычисляет значение только при первом обращении, а затем сохраняет его, чтобы не повторять дорогостоящее вычисление при последующих обращениях:
class CachedValue:
def __get__(self, instance, owner):
if self.name not in instance.__dict__:
instance.__dict__[self.name] = compute()
return instance.__dict__[self.name]Такой дескриптор особенно полезен, если compute() представляет собой, например, тяжёлый сетевой запрос или ресурсоёмкое вычисление, которое нежелательно повторять при каждом обращении к атрибуту.
Наконец, дескрипторы применяют для логирования доступа к атрибутам (когда нужно отследить каждое чтение или изменение конкретного поля в целях отладки) и для контроля доступа по правам (когда чтение атрибута разрешено только при выполнении определённого условия, например наличия соответствующих прав у текущего пользователя). Все эти случаи объединяет одно: логика, не зависящая от конкретного класса по своей сути, но применяемая к атрибутам многих разных классов.
Главная ловушка, уже разобранная выше, — присвоить внутреннему имени хранения то же значение, что и имени самого дескриптора, из-за чего __get__/__set__ начинают бесконечно вызывать сами себя через getattr/setattr. Именно поэтому __set_name__ всегда должен формировать имя, отличное от имени, под которым дескриптор виден снаружи (_x вместо x, а не x вместо x).
Вторая ловушка — забыть о разнице между data и non-data дескрипторами и удивиться, когда non-data descriptor «перестаёт работать» после присваивания. Как было показано, non-data descriptor уступает приоритет любой одноимённой записи, оказавшейся в __dict__ объекта, — и если требуется, чтобы дескриптор действительно контролировал запись, а не только чтение, необходимо реализовать __set__, даже если внутри него нет никакой содержательной логики, кроме сохранения значения.
Третья ловушка — путать место объявления дескриптора. Дескриптор всегда объявляется как атрибут класса (x = Integer() в теле Point3D), а не как атрибут объекта внутри __init__ — если по ошибке написать self.x = Integer() внутри __init__, никакого специального поведения не возникнет: это будет обычный объект, сохранённый как обычный атрибут конкретного экземпляра, без какого-либо перехвата чтения или записи.
Дескриптор — это класс, реализующий методы __get__, __set__ и, опционально, __delete__, объект которого, будучи сохранённым как атрибут класса, перехватывает все обращения к одноимённому атрибуту у объектов этого класса. Дескрипторы без __set__ (non-data descriptors) перехватывают только чтение и уступают приоритет содержимому __dict__ объекта; дескрипторы с __set__ (data descriptors) перехватывают чтение и запись безусловно, обладая приоритетом над __dict__ объекта в любом случае.
Главное практическое преимущество дескриптора перед property — возможность написать логику контроля доступа один раз и применить её к произвольному числу атрибутов и даже разных классов, не копируя код. Именно на этом механизме и построен сам property: он представляет собой встроенный, готовый к использованию data descriptor, рассчитанный на единственный атрибут одного класса. В прикладном программировании дескрипторы пишут вручную нечасто, полагаясь на property там, где переиспользование не требуется, — но на дескрипторах строится внутренняя механика многих фреймворков и библиотек, включая ORM-системы, где переиспользование логики полей между множеством моделей оправдывает дополнительную сложность.
В следующем модуле курса мы перейдём к магическим методам — тому обширному набору dunder-методов, который определяет, как объекты представляются в виде строк, сравниваются друг с другом, участвуют в арифметических операциях и ведут себя в цикле for. Понимание, разобранное в этом уроке, — то, что определённые имена методов заставляют Python вызывать их автоматически в строго предусмотренных ситуациях, — прямо продолжится и там, только на этот раз речь пойдёт не о доступе к атрибутам, а о поведении объектов в целом.
- Что делает объект дескриптором и какие методы для этого нужно реализовать?
- В чём разница между data descriptor и non-data descriptor?
- Почему дескрипторы помогают избежать дублирования кода по сравнению с
property? - Что делает метод
__set_name__и когда он вызывается? - Почему имя, вычисляемое в
__set_name__для хранения значения, обязательно должно отличаться от имени самого дескриптора? - Что произойдёт при попытке присвоить значение атрибуту, связанному с non-data descriptor?
- Почему data descriptor обладает более высоким приоритетом, чем non-data descriptor?
- Как связаны
propertyи дескрипторы? - Почему дескрипторы редко пишут вручную в прикладном коде, но активно используют в библиотеках и фреймворках?
Реализуйте дескриптор PositiveNumber, обеспечивающий, что связанное с ним значение — число (int или float), строго больше нуля. При попытке присвоить некорректное значение дескриптор должен выбрасывать ValueError. Примените дескриптор к классу Product для полей price и weight.
Пример использования:
p = Product(1000, 2.5)
print(p.price, p.weight) # 1000 2.5
p.price = -100 # ValueError: Значение должно быть положительным числомРеализуйте дескриптор StringField(min_len, max_len), проверяющий, что значение — строка, длина которой лежит в диапазоне [min_len, max_len]. Обратите внимание, что дескриптору в этом случае нужно принимать параметры при создании — реализуйте __init__ дескриптора. Примените его к классу Comment для полей author и text с разными допустимыми диапазонами длины.
Пример использования:
class Comment:
author = StringField(2, 50)
text = StringField(1, 500)
def __init__(self, author, text):
self.author = author
self.text = text
c = Comment("Егор", "Отличная статья!")
print(c.author, c.text)
c2 = Comment("И", "Текст") # ValueError: слишком короткое имя автораРеализуйте дескриптор ReadOnly(value), который хранит заранее заданное значение и разрешает только чтение — при попытке присвоить что-либо через этот дескриптор должно выбрасываться исключение AttributeError. Примените его к классу Config для поля version.
Пример использования:
class Config:
version = ReadOnly("1.0.0")
cfg = Config()
print(cfg.version) # 1.0.0
cfg.version = "2.0.0" # AttributeError: Это поле доступно только для чтенияРеализуйте дескриптор LoggedAttribute, который при каждом чтении выводит Чтение <имя>: <значение>, а при каждой записи — Запись <имя>: <значение>, и только затем выполняет саму операцию. __set_name__ сохраняет два разных имени: public_name — «человеческое» имя атрибута для вывода в лог ('temperature'), и name — внутреннее имя для фактического хранения значения ('_temperature'). Примените дескриптор к классу Sensor для поля temperature.
Пример использования:
class Sensor:
temperature = LoggedAttribute()
def __init__(self, temperature):
self.temperature = temperature
s = Sensor(20)
# Запись temperature: 20
print(s.temperature)
# Чтение temperature: 20
# 20
s.temperature = 25
# Запись temperature: 25Реализуйте дескриптор CachedProperty, вычисляющий значение только при первом обращении к нему и сохраняющий результат для всех последующих обращений (кеширование). В качестве вычисления используйте функцию, переданную дескриптору при создании. Продемонстрируйте на классе Report, где поле summary вычисляется через переданную «дорогую» функцию, вызываемую только один раз.
Пример использования:
def compute_summary(report):
return f"Отчёт по {len(report.data)} записям"
class Report:
def __init__(self, data):
self.data = data
summary = CachedProperty(compute_summary)
r = Report([1, 2, 3, 4, 5])
print(r.summary) # Значение вычислено заново \n Отчёт по 5 записям
print(r.summary) # Значение взято из кеша \n Отчёт по 5 записямРеализуйте дескриптор Choices(*allowed_values), ограничивающий присваиваемое значение заранее заданным набором допустимых вариантов. При попытке присвоить значение вне этого набора должно выбрасываться ValueError со списком допустимых вариантов в тексте сообщения. Примените дескриптор к классу Task для поля status с допустимыми значениями "new", "in_progress", "done".
Пример использования:
class Task:
status = Choices("new", "in_progress", "done")
def __init__(self, title, status="new"):
self.title = title
self.status = status
t = Task("Написать урок")
print(t.status) # new
t.status = "in_progress"
print(t.status) # in_progress
t.status = "cancelled" # ValueError: Допустимые значения: new, in_progress, done