Package: py-liant
py-liantTable of Contentspy-liantTable of ContentsIntroductionRESTful APIOpinionatedModified JSONHow to useReferenceJsonObjectJSONEncoderJSONDecoderpyramid_json_renderer_factorypyramid_json_decoderpatch_sqlalchemy_base_classmonkeypatch: obj.apply_changesCRUDViewConvertMatchdictPredicateCatchallPredicateCatchallViewHints syntaxDrilldown supportSingle element from collectionFiltering, sorting, paginationPolymorphic castingPolymorphic loading hintsPolymorphic identityImplicit filtersHint profilesJsonGuardProviderSearchPathSetterEnumAttrs and PythonEnumIntroductionPy-liant is a library of helpers for rapid creation of opinionated RESTful APIs
using pyramid and SQLAlchemy. It provides a read-write set of operations using
a slightly modified object-graph aware JSON structure which is tightly coupled
with the data models being exposed.It was created by Trip Solutions for internal projects but we feel it may prove
useful for general consumption.RESTful APITheCRUDViewbase class assumes the API follows REST conventions
and provides CRUD ([C]reate, [R]ead, [U]pdate, [D]elete) functionality, or a
subset of that. It does not make any assumptions about the endpoints, which are
still defined in user code. There are assumptions being made about the format of
the payloads, seeModified JSONandCrudViewOpinionatedTheCatchallViewbase class however provides a custom parser
for the URL string and is heavily opinionated about the structure of the API.
This allows it to be effortlessly deployed on top of existing SQLAlchemy data
structures but has the disadvantage of being less customizable.Modified JSONORM data models are not always trees. Any real-world application beyond a
certain complexity level is bound to get to a point where mapping deep data
models directly to JSON isnot
feasible.
In our first iterations we've worked around this issue by manually decoupling
the JSON from the structure, but any manual process quickly turns into a time
sink; it adds a lot of complexity for both client and server code.Py-liant solves the graph awareness issue by reserving two keywords for internal
use in the JSON graph. Any object that needs to be referenced from within the
JSON structure will get a special key_idwith a generated value. References
to an object are codified using an object with a sigle key_refmatching the_idof the referenced object. Please note, this is only true for SQLAlchemy
model objects.For example, given the model declaration below:fromsqlalchemy.ormimportrelationship,backreffromsqlalchemyimportColumn,Integer,Text,ForeignKeyfromsqlalchemy.ext.declarativeimportdeclarative_baseBase=declarative_base()classParent(Base):__tablename__='parent'id=Column(Integer,primary_key=True)data=Column(Text)classChild(Base):__tablename__='child'id=Column(Integer,primary_key=True)parent_id=Column(ForeignKey(Parent.id))data=Column(Text)parent=relationship(Parent,backref=backref('children'))the following code snippentfrompy_liant.json_encoderimportJSONEncoderencoder=JSONEncoder(base_type=Base,check_circular=False,indent=4*' ')parent=Parent(id=1,data="parent object")parent.children.extend([Child(id=1,data="child 1"),Child(id=2,data="child 2")])print(encoder.encode(parent))will output{"id":1,"data":"parent object","children":[{"id":1,"data":"child 1","parent":{"_ref":1},"_id":2},{"id":2,"data":"child 2","parent":{"_ref":1},"_id":3}],"_id":1}The encoder will also extract metadata information from SQLAlchemy models to
support serialization. It will serialize only column and relationship
properties, which means it will not display any non-SQLAlchemy properties. It
also expects all relationships to be eagerly loaded and will avoid triggering
any lazy-loaded properties. Deferred columns are also avoided.Conversely, theJSONDecoderwill turn a simlarly codified JSON
structure and return a completed graph, with potentially cyclic or multiple
references, for use in the application.The decoder will generate a structure ofJsonObjects. If the base
class is patched usingpatch_sqlalchemy_base_class,
the decoded object can be used to patch an existing or new SQLAlchemy model
instance.We also provide a pair of encoder / decoder functions for use in javascript
inpyliant.js.How to useIn pyramid's config block you can override the default JSON renderer using the
following:frompy_liant.pyramidimportpyramid_json_renderer_factoryconfig.add_renderer('json',pyramid_json_renderer_factory(Base))Then userenderer='json'in any@view_config()oradd_view().You can use py-liant's JSON decoder by adding the following in pyramid's config:frompy_liant.pyramidimportpyramid_json_decoderconfig.add_request_method(pyramid_json_decoder,'json',reify=True)Thus, for any request with a JSON payload in body you can access the decoded
JsonObject structure usingrequest.json.Patching the SQLAlchemy model's base class:patch_sqlalchemy_base_class(Base)Adding the view predicates:config.add_view_predicate('convert_matchdict',ConvertMatchdictPredicate)config.add_view_predicate('catchall',CatchallPredicate)Py-liant also provides a callable factory to do all of the above:frompy_liant.pyramidimportincludeme_factoryconfig.include(includeme_factory(base_class=Base))# identical to includeme_factory(base_class=Base)(config)Concrete usage examples ofCRUDViewandCatchallViewcan be found in the reference documentationReferenceJsonObjectThis class is adictimplementation that exposes all string keys as
properties. It eliminates the need to access dictionary values using index
notation (request.json['prop']becomesrequest.json.prop). TheJSONDecoderreturns instances of this class.JSONEncoderAsimplejson.JSONEncoderimplementation that adds the following:convertsdate,timeanddatetimeobjects to ISO8859 stringsconvertsbytevalues to Base64strigifies pythonEnumvalues to their name,uuid.UUIDvaluestracks SQLAlchemy models (if provided a base class) as discussed inModified JSONConstructor arguments:JSONEncoder(request=None,base_type=None,**kwargs)requestshould be a pyramid request object. If provided it's used to applyJsonGuardProviderfencing for serialization.base_typeis the SQLAlchemy models base class. If not provided the
functionality related to SQLAlchemy is disabled.kwargsis passed tosimplejson.JSONEncoder's constructorJSONDecoderAsimplejson.JSONDecoderimplementation that returns aJsonObjectas a result and handles_id/_reflogic as
described inModified JSON.Constructor argumets:JSONDecoder(**kwargs)**kwargsis passed tosimplejson.JSONDecoder's constructor.pyramid_json_renderer_factoryFactory for a pyramid renderer that provides JSON serialization usingJSONEncoder. SeeHow to usefor usage.Arguments:pyramid_json_renderer_factory(base_type=None,wsgi_iter=False,separators=(',',':'))base_typeandseparatorsare passed toJSONEncoder's
constructor. The default value forseparatorsis meant to minimize payload
size by skipping any unnecessary spaces.wsgi_itercan be used to optimize rendering of JSON by passing an iterable
directly to the WSGI layer. By default the renderer writes directly in the
pyramidresponseobject. When activated, pyramid can no longer handle error
redirects for execptions thrown during serialization.pyramid_json_decoderThis is a fucnction that can be added to pyramid usingconfig.add_request_method. SeeHow to usefor usage.patch_sqlalchemy_base_classThis is the function that adds the methodapply_changesto SQLAlchemy's base class.monkeypatch: obj.apply_changesobj.apply_changes(data,object_dict=None,context=None,for_update=True)Once SQLAlchemy's base class is patched usingpatch_sqlalchemy_base_class, all model instances get
a method that can be used to apply patches. This can be used directly but most
of the time, if you useCRUDViewand/orCatchallView, you won't have to.The method will apply changes in any depth required. It converts the data types
based on metadata extracted from SQLAlchemy. It handles relationships, both
collections and instances, by tracking and comparing the primary keys provided in JSON.
Where needed it will add new instances.For an object without relationships it applies the values fromdatato their
corresponding column properties inobj. No property values are overwritten
unless specified in thedataobject.If an object has relationships, thedataobject can drill down into them. For
collection relationships theapply_changesmethod expects all objects to be
provided in the corresponding array, at a minimum with their primary key
present. If a member of the array does not provide a primary key it is presumed
to be a new instance. If a member of the object's collection cannot be tracked
back to a member of the array in data, it will be removed from the collection.If the primary key of the descendants is a composite that includes any of the
columns in the foreign key, the caller can provide the partial primary key and
py-liant will reconstruct the remaining columns based on the relatonship to the
parent.If a pyramidcontextis provided that implementsJsonGuardProvider, it will be used for security fencing
the patching.CRUDViewThis class provides CRUD functionality for a given model class. You can
configure the routes and views as needed for your application but the
recommended way is shown below:config.add_route('parent_pk','parent/{id}')config.add_route('parent_list','parent')@view_config(route_name='parent_pk',request_method='GET',attr='get')@view_config(route_name='parent_pk',request_method='POST',attr='update')@view_config(route_name='parent_pk',request_method='DELETE',attr='delete')@view_config(route_name='parent_list',request_method='GET',attr='list')@view_config(route_name='parent_list',request_method='POST',attr='insert')classParentView(CRUDView):target_type=Parenttarget_name='parent'def__init__(self,request):super().__init__(request)self.filters=self.auto_filters()self.accept_order=self.auto_order()defidentity_filter(self):returnParent.id==int(self.request.matchdict('id'))This is enough to provide a complete read-write endpoint for objects of typeParent.UseGET /parent/1 HTTP/1.1to retrieve parent with id=1. It should return
something along the lines of:{"parent":{"id":1,"data":"parent object","_id":1}}UsePOST/parent/1HTTP/1.1{
  "parent": {
    "data": "parent object changed"
  }
}to update the data in instance of parent with id=1.Posting to/parentinstead of/parent/1will create a new instance instead
of updating an existing one.DELETE /parent/2 HTTP/1.1will delete the parent with id=2.Finally,GET /parent HTTP/1.1will provide a list of all parent instances in
the database.For the listing endpoint the following response will be returned:{"items":[{"id":1,"data":"parent object","_id":1}],"total":1}TheCRUDViewclass also offers pagination support, implicit and explicit
filtering, implicit and explicity sorting.Pagination is supported via GET parameterspageandpageSize(i.e.,GET /parent?page=3&pageSize=20).Implicit filters and sorting are provided for all column properties. Assuming
column propertiesidanddatafor classUser, the following filters will be
added toself.filters(in the example usage above, during construction, see
theauto_filters()call):id,id_lt,id_le,id_gt,id_ge,id_isnull,id_in,data,data_lt,data_le,data_gt,data_ge,data_like,data_isnull,data_in. The filters[field_name]_[operator]provide filtering using theless-than,less-or-equal,greater-than,greater-or-equal,contains,is-nullandinoperators. Thecontainsoperator is automatically generated for string column properties only. Theis-nulloperator accepts a boolean-like value and has the effect of applying
the SQLIS NULLoperator if given a truthy value andIS NOT NULLoperator
if given a falsey value. Theinoperator accepts a comma-delimited list of
values and checks if the field contains one of the listed values.Automatic sorting keys are also added (in the example usage above see the call toauto_order()) for both fields.Filtering in a listing endpoint is done as such:GET /parent?data_like=object.
Multiple filters can be applied, i.e.GET /parent?id_lt=10&id_gt=5.Sorting is done by using the GET parameterorder, i.e.GET /parent?order=data. Multipe sorting expressions can be applied, i.e.order=data,id. In other words the value passed inorderis a comma-separated
list of sorting keys. Each sorting key also accepts the descending modifier,
i.e.order=data+desc,id.Sorting and filtering keys can also be manually defined.
In the usage example above we could have defined some filters and orderings by hand as such:classParentView(CRUDView):filters={'id':lambda_:Parent.id==int(_),'id_lt':lambda_:Parent.id<int(_),'data':lambda_:Parent.data==_,'data_like':lambda_:Parent.data.contains(_)}accept_order={'data':Parent.data,'data_lowercase':func.lower(Parent.data)}Doing this is obviously more laborious but allows you to define custom filters or soring expressions.The implementation assumesrequest.dbsessionis a request method that returns
a SQLAlchemy database session valid for the model.ConvertMatchdictPredicateIf pyramid has been configured to use this predicate as indicated inHow to
use, you can get around the need to convert matchdict parameters.Pyramid'sURL
Dispatchdocumentation page shows the following example for URL matchdict conversion:defintegers(*segment_names):defpredicate(info,request):match=info['match']forsegment_nameinsegment_names:try:match[segment_name]=int(match[segment_name])except(TypeError,ValueError):passreturnTruereturnpredicateymd_to_int=integers('year','month','day')config.add_route('ymd','/{year}/{month}/{day}',custom_predicates=(ymd_to_int,))This code ensures both that the route will not match unless predicate executes
succesfully (returnsTrue) and that the view will see integer values for keysyear,monthanddayinrequest.matchdict. While this is very useful it
is unfortunately deprecated functionality. Sice pyramid-1.5 you will get a
deprecation warning when usingcustom_predicatesin routes or views.To replace this functionality with supported mechanisms we've implemented a
generic new-style route predicate class. To use this class in your routes you
first have to configure it as described inHow to use. Then in
the example in the previous section the view configs for routeparent_pkshould change as follows:@view_config(route_name='parent_pk',request_method='GET',attr='get',convert_matchdict=(int,'id'))@view_config(route_name='parent_pk',request_method='POST',attr='update',convert_matchdict=(int,'id'))@view_config(route_name='parent_pk',request_method='DELETE',attr='delete',convert_matchdict=(int,'id'))Please note that while in the oldcustom_predicatesmethod the conversion of
the matchdict parameters was done at route level, the new-style route predicates
do not have access to the matchdict. Therefore we have to use view predicates to
achieve the same.After these changes you no longer need theint()cast in theidentity_filter()method. You'll also avoid the need to catch theValueErrorexception.CatchallPredicateThis is a supporting predicate to be used withCatchallView. It
assumes the route contains a fizzle parameter of the form{catchall:.*}(NOT*catchall, since the star format creates an array of string values from the
match) that is then parsed internally and converted to values better suited for theCatchallViewclass.CatchallViewThis is an extension of theCrudViewclass that adds support for a
far richer route format based on internal parsing done by theCatchallPredicateand has the ability to:expose multiple entity types in a single placeoffer arbitrary eager loading depth, as specified in the route's loading hintsdrill into both dynamic and static relationshipsoffer slice syntax for easier paginationTo use this class:# setup routeconfig.add_route("catchall",'{catchall:.*}')# declare the class@view_defaults(renderer='json',catchall={'parent':Parent,'child':Child})@view_config(route_name="catchall",attr='process')classMyCatchallView(CatchallView):passThis code is enough to expose routes such as:GET /parentorGET /childto list all parents or childrenGET /parent@1orGET /child@1to get parent with id=1 or child with id=1POST /parentorPOST /childto add a new parentPOST /parent@1to update properties for parent with id=1DELETE /parent@1,DELETE /child@1to delete parent with id=1 or child with
id=1In other words, both entity typesParentandChildare accessible from a
single point.Hints syntaxHowever, from your application's perspective alllowing access toChildat the root level might not be something useful, in other words you
might want your API to regardChildas tightly bound toParent. CatchallView
allows you to get a parent entity and all children attached in one go usingGET /parent@1:*children. The CatchallView will see the portion of the route coming
after the column character as a list of loading hints forParententity. In
this case, it attaches aselectinload(Parent.children)option to the query.The hints will also allow you to hide properties that might be too large, by
deferring them. I.e. if you added ablobproperty onParentand the caller
might want to avoid retrieving it, they could callGET /parent@1:-blob.
Conversely, if theblobproperty is marked as a deferred column in the model
declaration but the caller would want it included in the response they can
undefer it by callingGET /parent@1:+blob.If we also added ablobcolumn for theChildentity (let's assume it's a
deferred column in the code), the caller can get a parent with all children
including the blob for each by callingGET /parent@1:*children(+blob).
Multiple hints can be provided by comma separating them. This is also the case
for relationship hints:GET /parent@1:-blob,*childrenmeans "loadParentwith allchildrenincluded and defer loading the columnParent.blob".GET /parent@1:-blob,*children(+blob,-blob2,*second_parent)means "loadParentwith allchildrenincluded, defer columnParent.blobandChild.blob2and
undefer columnChild.blob. For each child also load the relationshipChild.second_parent.The hints can have arbitrary depth. Each relationship hint can have hints
referring to the entities of that relationship.Hints are also applicable to listing requests:GET /parent:*childrenwill
effectively retrieve all parents and all associated children.Please note: dynamic relationship properties cannot be the target of a
relationship hint.Drilldown supportIf the caller wanted to retrieve just the children of a parent of known id they
could callGET /parent@1/children. The last bit of the route is not a hint,
it's a drilldown specifier. This constructs a query that retrieves all children
for parent with id=1, by reading the foreign key constraint of relationshipParent.children.The drilldown supports both normal relationship properties as well as dynamic
relationship properties. It automatically determines if the target property is a
list or a single entity (i.e.GET /child@1/parentalso works). All hints
provided must come after the drilldown specifier and they will refer to the
entities in the relationship being drilled down into. For example, in the requestGET /parent@1/children:+blobthe hint will undefer loading of columnChild.blob.If the property being drilled into is a collection allFiltering, sorting and
paginationconsiderations apply.Single element from collectionIf the request either refers to a collection property viaDrilldownor refers to a collection of entities because it
does not contain a primary key specifier, the caller can select a single item
from the list by using subscript notation. For example,GET /parent@1/children[0]will retrieve the first child of theParent.childrencollection.Filtering and sortingare applied
first.Filtering, sorting, paginationFiltering, sorting and pagination are applied as described in theCRUDViewsection. Onlyauto_filtersandauto_orderare used.
Support for custom expressions is upcoming.Pagination as supported byCRUDViewis also supported however the
same subscript notation as described in the previous section can be used for
slicing:GET /parent[0:10]?order_by=data+descretrieves the first 10Parententities in descendingdataorder.Polymorphic castingSupposeParentis a polymorphic type defined similar to the following:classParent(Base):__tablename__='parent'id=Column(Integer,primary_key=True)_type=Column('type',Text)data=Column(Text)__mapper_args__=dict(polyomrphic_on=_type)and derived classes defined as follows:classParentFather(Parent):__mapper_args__=dict(polymorphic_identity='father')classParentMother(Parent):__mapper_args__=dict(polymorphic_identity='mother')If route/parentpoints to classParentwe can use polymorphic casting to access derived types; i.e./parent!fatherwill point to classParentFatherand/parent!motherwill point to classParentMother. The polymorphic casting syntax is/<route>!<identity>where<identity>refers to thepolymorphic_identityvalue defined in each derived class' mapper arguments.Using the polymorphic casting syntax exposes all derived class' fields and relationships in the resulting JSON, exposes all fields toauto_orderandauto_filtersoutput, allows hints to refer to derived class` fields and relationships.Creating a derived class instance is also possible by performing aPOST /parent!<identity>.Polymorphic casting is also supported for drilldown collections, i.e.GET /parent@1/children!girl. This only works for non-dynamic collections.Polymorphic loading hintsSometimes you may want to access a collection like/parentwithout polymorphic casting to get acess toParents of all types but may wish to provide specific loading hints for each dervide type. Provided the derived classes had specific collections and fields (say,father_data,mother_datawere relationships defined specifically forParentFatherandParentMotherrespectively) they can be referred to in the loading hints as such:/parent:!father(*father_data),!mother(*mother_data))It's necessary to declarewith_polymorphic='*'in the base class mapper arguments for the loading hints to take effect. At the moment py-liant does not offer a mechanism to force polymorphic loading when not defined in the mapper.Polymorphic loading hints can also be applied to relationships with polymorphic classes. Consider thatChildwas polymorphic and hadChildGirlandChildBoydefinitions with discrimiators set to the values"girl"and"boy". Accessing thechildrencollection ofParentwould not normally allow you to specify loading hints for properties that were not generic. However you can use the following hints to specify eager loading for the derived class specific relationships:/parent:*children(!boy(*boy_data),!girl(*girl_data))Polymorphic identityThe identity value used in both types of polymorphic functionality described above is automatically cast to the polymorphic identifier type. In the examples above the type was string but any supported type can be used.Enumerablesare encouraged.Implicit filtersWhen defining the catchall view targets using@view_config'scatchallparameter dictionary, the value passed for each key can be richer than just a target type. You can pass a tuple, an array, a dictionary or an instance ofCatchallTarget. When passing a tuple, array or dictionary it is passed unmodified to the constructor ofCatchallTarget.One possible use for this is to associate a set of filters with a target type. The filter would be applied in addition to any filters provided in the query parameters of the request, except when using the primary key access syntax (/target@pkey).For example:@view_defaults(renderer='json',catchall={'parent':{'cls':Parent,'filters':Parent.active.is_(True)})@view_config(route_name="catchall",attr='process')classMyCatchallView(CatchallView):passwould ensure that onlyParentinstances with a boolean propertyactiveset to True would be returned except when accessed using the primary key. Multiple conditions can be provided either by providing an array of conditions (all are applied) or constructing a more complex SQL expression using logic operators (sql.and_,sql.or_, etc).Filters can also be defined as a callable that accepts the pyramid request as an argument and returns
the filter(s) dynamically. This is useful to provide a substitute forCRUDView's context filters.Hint profilesTheCatchallTarget.profilesproperty can be used to provide quick access to loading hint profiles defined server-side. Consider the following example:@view_defaults(renderer='json',catchall={'parent':{'cls':Parent,'profiles':{'with_children':'*children','with_data':'*data'}})@view_config(route_name="catchall",attr='process')classMyCatchallView(CatchallView):passThe profiles can be accessed in an HTTP request using the following syntax:GET /praent:with_childrenThe hints in the profile can be overridden in the request, or other hints can be provided on top of the ones in the profile:GET /parent:with_children:*dataThe full syntax of the loading hints is available in the profile definition.JsonGuardProviderFor security considerations the flexibility offered by this library can be
detrimental. Model classes can contain references to entities that need to be
protected from the API, both in terms of reading them (when usingCatchallView) and in terms of updating them (concernsany
insert/update method).TheJsonGuardProviderinterface allows you to add security fencing for four
areas:methodguardSerializeallows you to control how much information gets
serialized to JSONmethodguardUpdateallows you to control what can be written into the
entities wheneverobj.apply_changes()get calledmethodguardHintsallows you to control whatCatchallView
hintsare permittedmethodguardDrilldownallows you to control what properties can bedrilled downinto viaCatchallViewTo use aJsonGuardProvider, implement this interface in a Pyramidcontextand attach it to the route and view usingadd_route'sfactory.For example:frompy_liant.interfacesimportJsonGuardProviderclassMyContext(JsonGuardProvider):request=None# provide some ACLs, for use with ACLAutorizationPolicydef__acl__(self):# let's assume any authenticated user should have read accessifself.request.method=='GET':return[(Allow,Authenticated,"process")]# if request verb is POST or DELETE, require admin rolereturn[(Allow,"role:admin","process")]def__init__(self,request):# we need to look at the request in the implementationself.request=requestdefguardSerialize(self,obj,value):# always hide Child.second_parentifisinstance(obj,Child)and'second_parent'invalue:# do NOT modify obj, just change value (JsonObject)delvalue.second_parentdefguardUpdate(self,obj,data,for_update=True):# apply custom changes to the input data, for example encrypt passwordsifisinstance(obj,User)and'password'indata:# for example passwords can be encrypteddata.password=hash(data.password)# or prevent certain properties being written into by the updateifisinstance(obj,Parent):if'property'indata:deldata.property# or apply mandatory changes to certain objects# TrackedInstanceMixin could be a mixin that adds 'added' and# 'last_updated' columns to entitiesifisinstance(obj,TrackedInstanceMixin):# for_update is set to true when obj is newly instantiatedifnotfor_update:obj.added=datetime.now(timezone.utc)obj.last_updated=datetime.now(timezone.utc)# if returning falsey value processing for this entity and all# descendants is preventedreturnTruedefguardHints(self,cls,hints):# maniupate the hints provided by the caller# e.g. remove any hint for Parent.dataifclsisParentandParent.datainhints:delhints[Parent.data]# or add default hints for certain classesifclsisChildandChild.datanotinhints:hints[Child.data]=('-',None)ifclsisChildandChild.parentnotinhints:hints[Child.parent]=('*',[('+',Parent.blob)])defguardDrilldown(self,prop)->bool:ifpropisParent.children:returnFalsereturnTrue# change the rotue definition to include context factoryconfig.add_route("catchall",'{catchall:.*}',factory=MyContext)SearchPathSetterThis is a PostgreSQL specific addition that can be used to set up the schema
search path for all newly created database connection. It's implemented as a
SQLAlchemyPoolListener(deprecated since version 0.7). A replacement that
uses the modern events API is currently in the works.It is very unlikely you will need to use this class in your project unless you
need to use multi-tenant databases with configurable schemas.EnumAttrs and PythonEnumPythonEnumis a custom implementation ofsqlalchemy.types.Enumthat is
useful in PostgreSQL for declaring named enum types.Usage:fromenumimportEnumfrompy_liant.enumimportEnumAttrs,PythonEnum# in PostgreSQL this will generate:# CREATE TYPE user_type AS ENUM ('admin', 'operator', 'user')@EnumAttrs('user_type')classuser_type(Enum):admin='admin'operator='oeprator'user='user'classUser(Base):__tablename__='parent'id=Column(Integer,primary_key=True)name=Column(Text)user_type=Column(PythonEnum(user_type))