Package: py-meta-utils
Py Meta UtilsUseful LinksOfficial Documentation on Read The DocsSource Code on GitHubPyPIThe Meta Options Factory Pattern as a library, and related metaclass utilitiesWhen you as a library or framework author want your end users to be able to write something like this:classSomeClass(YourBaseClass):classMeta:option1='value1'option2='value2'option3='value3'And you need a way to define each of these option/value pairs, and a way to "attach" custom behavior to them (ie, code that manipulates code using a custommetaclassonYourBaseClass). There are a couple common-ish patterns to accomplish this. Django and Graphene have one way, Marshmallow another, and Factory Boy another (and no doubt probably others). But Factory Boy's implementation is by far the most powerful and flexible one I've come across.I discovered this pattern while reading the source code offactory_boy(specifically,this file).And I decided to extract it and turn it into a reusable library. In the process, I ended up refactoring a few things and adding a couple niceties to improve upon its usage.Let's take a look at a silly example to allow your end users to be able to optionally enable logging of the actions of a class from a library you're writing:classEndUserClass(YourLoggableService):classMeta:debug:bool=Trueverbosity:int=2log_destination:str='/tmp/end-user-class.log'The first step is to define your customMetaOptionsubclasses:All that's absolutely required to implement is the constructor and itsnameargument. That said, it's recommended to also specify thedefaultandinheritarguments for the sake of being explicit.Thecheck_valuemethod is optional, but useful for making sure your users aren't giving you garbage.Theget_valuemethod has a default implementation that normally you shouldn't need to override, unless your default value is mutable or you have advanced logic.There's also acontribute_to_classmethod that we'll cover later on.importosimportsys# first we have to import what we need from py_meta_utilsfrompy_meta_utilsimport(McsArgs,MetaOption,MetaOptionsFactory,process_factory_meta_options,_missing)# then we have to declare the meta options the meta options factory should supportclassDebugMetaOption(MetaOption):def__init__(self):super().__init__(name='debug',default=False,inherit=True)defcheck_value(self,value,mcs_args:McsArgs):ifnotisinstance(value,bool):raiseTypeError(f'The{self.name}Meta option must be a bool')classVerbosityMetaOption(MetaOption):def__init__(self):super().__init__(name='verbosity',default=1,inherit=True)defcheck_value(self,value,mcs_args:McsArgs):ifvaluenotin{1,2,3}:raiseValueError(f'The{self.name}Meta option must either 1, 2, or 3')classLogDestinationMetaOption(MetaOption):def__init__(self):super().__init__(name='log_destination',default=_missing,inherit=True)# this pattern is useful if you need a mutable default value like [] or {}defget_value(self,Meta,base_classes_meta,mcs_args:McsArgs):value=super().get_value(Meta,base_classes_meta,mcs_args)returnvalueifvalue!=_missingelse'stdout'defcheck_value(self,value,mcs_args:McsArgs):ifvaluein{'stdout','stderr'}:returntry:dir_exists=os.path.exists(os.path.dirname(value))except:dir_exists=Falseifnotdir_exists:raiseValueError(f'The{self.name}Meta option must be one of `stdout`, ''`stderr`, or a valid filepath')The next step is to subclassMetaOptionsFactoryand specify theMetaOptionsubclasses you want:classLoggingMetaOptionsFactory(MetaOptionsFactory):_options=[DebugMetaOption,VerbosityMetaOption,LogDestinationMetaOption,]Then you need a metaclass to actually apply the factory options:classLoggingMetaclass(type):def__new__(mcs,name,bases,clsdict):mcs_args=McsArgs(mcs,name,bases,clsdict)process_factory_meta_options(mcs_args,LoggingMetaOptionsFactory)returnsuper().__new__(*mcs_args)And lastly, create the public class, using the metaclass just defined:classYourLoggableService(metaclass=LoggingMetaclass):defdo_important_stuff(self):ifself.Meta.verbosity<2:self._log('doing important stuff')else:self._log('doing really detailed important stuff like so')def_log(self,msg):ifnotself.Meta.debug:returnifself.Meta.log_destination=='stdout':print(msg)elifself.Meta.log_destination=='stderr':sys.stderr.write(msg)sys.stderr.flush()elifself.Meta.log_destination:withopen(self.Meta.log_destination,'a')asf:f.write(msg)The options factory automatically adds theMetaattribute to the class-under-construction (in this example,YourLoggableService). (In this case theMetaattribute will be populated with the default values as supplied by theMetaOptionsubclasses specified by the factory.) In the case where the class-under-construction has a partialMetaclass, the missing meta options will be added to it.(*)(*) In effect that's what happens, and for all practical purposes is probably how you should think about it, but technically speaking, the class-under-construction'sMetaattribute actually gets replaced with a populated instance of the specifiedMetaOptionsFactorysubclass.The one thing we didn't cover isMetaOption.contribute_to_class. This is an optional callback hook that allowsMetaOptionsubclasses to, well, contribute something to the class-under-construction. Most likely it adds/removes attributes to/from the class, or perhaps it wraps some method(s) with a decorator or something else entirely.A good simple example can be found in the source code for the includedAbstractMetaOption:ABSTRACT_ATTR='__abstract__'classAbstractMetaOption(MetaOption):def__init__(self):super().__init__(name='abstract',default=False,inherit=False)defget_value(self,Meta,base_classes_meta,mcs_args:McsArgs):# class attributes take precedence over the class Meta's valueifmcs_args.clsdict.get(ABSTRACT_ATTR,False)isTrue:returnTruereturnsuper().get_value(Meta,base_classes_meta,mcs_args)isTruedefcontribute_to_class(self,mcs_args:McsArgs,value):ifvalueisTrue:mcs_args.clsdict[ABSTRACT_ATTR]=Trueelse:mcs_args.clsdict[ABSTRACT_ATTR]=FalseA number of libraries use the__abstract__class attribute to determine whether or not the class-under-construction should be considered concrete or not, but they won't understand classMetaoptions. Therefore, we implementMetaOption.contribute_to_classto set the__abstract__class attribute to the appropriate value for backwards compatibility with such libraries.Included Metaclass UtilitiesSingletonSingletonis an included metaclass that makes any class utilizing it a singleton:frompy_meta_utilsimportSingletonclassYourSingleton(metaclass=Singleton):passinstance=YourSingleton()assertinstance==YourSingleton()Classes usingSingletoncan be subclassed, however, you must inform the base class of your subclass:frompy_meta_utilsimportSingletonclassBaseSingleton(metaclass=Singleton):passclassExtended(BaseSingleton):passBaseSingleton.set_singleton_class(Extended)base_instance=BaseSingleton()extended_instance=Extended()assertbase_instance==extended_instance==BaseSingleton()==Extended()deep_getattrdeep_getattr(clsdict,bases,'attr_name',[default])deep_getattracts just likegetattrwould on a constructed class object, except this operates on the pre-class-construction class dictionary and base classes. In other words, first we look for the attribute in the class dictionary, and then we search all the base classes (in method resolution order), finally returning the default value if the attribute was not found in any of the class dictionary or base classes (or it raisesAttributeErrorifdefaultnot given).OptionalMetaclass and OptionalClasstry:fromoptional_dependencyimportSomeClassexceptImportError:frompy_meta_utilsimportOptionalClassasSomeClassclassOptional(SomeClass):passLicenseMIT