config system - #1080
config system#1080
Conversation
|
@clewis7 r4r This looks large but the main contribution is The rest is is just adding the decorators and stuff to register each class relevant method. |
|
|
||
|
|
||
| class Graphic: | ||
| config = global_config.descriptor |
There was a problem hiding this comment.
@clewis7 you might be wondering we have this whole descriptor stuff and it's because we can't do:
class A:
config = global_config._registry[A]Because A doesn't exist yet when the python interpreter is creating the config variable! By using the descriptor you don't need A to exist yet, it only needs to exist when the user calls A.config 😆
The interpreter parses everything in the class and creates all declared objects in the class (methods as well) and the class is created only after everything in the class has been created! It's like filling a cup of water but the cup exists after all the water has been poured into it 😂
| class ConfigDescriptor: | ||
| """Descriptor pattern so classes can access their configuration for users to set/get config options""" | ||
|
|
||
| def __init__(self, classes): | ||
| self.__classes = classes | ||
|
|
||
| def __get__(self, instance, cls: type = None): | ||
| if instance is not None: | ||
| raise AttributeError("set config options on the class, not an instance") | ||
|
|
||
| if cls not in self.__classes.keys(): | ||
| raise AttributeError("Class is not registered") | ||
|
|
||
| return self.__classes[cls] | ||
|
|
||
| def __set__(self, obj, value): | ||
| raise AttributeError("Cannot set") |
There was a problem hiding this comment.
@clewis7 See comment on base Graphic if you're wondering why this exists
|
I think I can just get rid of ConfigValue and replace using this https://stackoverflow.com/questions/17625695/is-it-possible-to-change-a-functions-default-parameters-in-python Then I can just specify which arguments are configurable on the method decorator, and it populates the defaults in the wrapper, and inject the config values into |
| def declare(self, *configurable): | ||
| """ | ||
| Declare configurable arguments for a method. | ||
| """ | ||
| if not configurable: | ||
| raise IndexError( | ||
| "No configurable arguments declared, this cannot be left empty. " | ||
| "Either declare configurable arguments or don't decorate this method." | ||
| ) | ||
|
|
||
| def append_to_config(method): | ||
| new_pending = Pending(method, configurable) | ||
| if self._pending and not new_pending.is_sibling(self._pending[-1]): | ||
| raise TypeError( | ||
| f"{self._pending[-1].cls_qual} is not registered with the global config" | ||
| ) | ||
|
|
||
| self._pending.append(new_pending) | ||
|
|
||
| # keep these to use them in the injector | ||
| method_name = new_pending.name | ||
| # create signature object just once when the method is decorated instead of every time the method is called | ||
| sig = inspect.signature(method) | ||
|
|
||
| # NOTE: variables within here are available in the injector because they exist in its __closure__ | ||
| # any variables from the outer function that are used in the inner function are always in the __closure__ | ||
| # source: https://stackoverflow.com/questions/14413946/what-exactly-is-contained-within-a-obj-closure | ||
| # official docs: https://docs.python.org/3/reference/datamodel.html#function.__closure__ | ||
|
|
||
| @wraps(method) | ||
| def injector(instance, *args, **kwargs): | ||
| # get the method config dataclass | ||
| method_config = getattr(type(instance).config, method_name) | ||
|
|
||
| # create a binding | ||
| try: | ||
| binding = sig.bind(instance, *args, **kwargs) | ||
| except TypeError as e: | ||
| # if *args and **kwargs don't match the signature raises a TypeError | ||
| # useful if the user passed wrong things, we need to catch and tell them what method it was | ||
| # since binding has no idea of the full namespace when we're handling it here | ||
| raise TypeError(f"{method.__qualname__}: {e}") from None | ||
|
|
||
| config_dict = method_config.to_dict() | ||
| # merge config values with the binding | ||
| # any values that the user explicitly provided will be in binding.arguments | ||
| # therefore an explicit user provided value will override the config value | ||
| binding.arguments = {**config_dict, **binding.arguments} | ||
|
|
||
| # apply any missing default vals from the method signature | ||
| # this isn't actually necessary but is just a robust failsafe | ||
| # I think it should account for any weirdness with methods that have positional-only arguments | ||
| binding.apply_defaults() | ||
|
|
||
| # finally call method with updated binding from config | ||
| return method(*binding.args, **binding.kwargs) | ||
|
|
||
| return injector | ||
|
|
||
| return append_to_config |
There was a problem hiding this comment.
@clewis7 fanciest decorator I've written 😂 (updated version which is even more elegant)
|
Merging, @clewis7 has agreed to fast-track this PR. |

An actually maintainable config system.
add_<graphic>methods by using fancie descriptor logicUser API
Some presets:
Presets can be stacked, they "merge" into each other, so
fpl.presets.light()followed byfpl.presets.compact()will apply both. This of course has to be done sensibly,fpl.presets.light()followed byfpl.presets.dark()just reverts things. Each preset just sets config values, that's all.Registering a class to the config system
To summarize
Rules:
@global_config.registerconfigset toglobal_config.descriptor@global_config.seton them, or its parent classes must have them.@global_config.register, however if this subclass is overriding methods in the parent class which have@global_config.setI don't think it has to be registered but we don't have a test case for this in fastplotlib AFAIK.add_line(data=...)will inject the colors from theglobal_config, if we doadd_line(data=..., colors="r")the explicitly providedcolors="r"will be used and the config value will be ignored.All rules above are necessary for a class to be in the config system.
Other notes:
@global_config.setuses its parent's config values. For exampleImguiFigureis registered, but theImguiFigureconstructor has no@global_config.setso it'll use values from the parent, i.e. the config object is shared.How this works
This is some fancie python. Relies on the fact that the class decorator executes after the class has been created, and therefore after all the method decorators have also been executed. So we populate a "_pending" list with method defaults, and then the class
@config.registerdecorator triggers that class to be registered with those defaults for its methods.The rest is really just some parsing, and the actual "config objects", i.e.
LineGraphic.configis a dataclass so that you can tab complete, and thenLineGraphic.config.inititself is also a dataclass with each argument-value pair for that method, so users can just doLineGraphic.config.init.colors = "r". This is much better than matplotlib rcParams where everything is just a string you have to remember: https://matplotlib.org/stable/users/explain/customizing.html#runtime-rc-settingsAlso relies on proper namespacing of the methods using the qualifying name and making sure everything matches up at all levels of this process, relies on the same system that
pickleuses for knowing the identity of classes it serializes (i.e.__qualname__), straight from the horse's mouth:https://github.com/python/cpython/blob/978fba58aef347de4a1376e525df2dacc7b2fff3/Lib/pickle.py#L1050-L1062
Better Mixin with descriptors!
The script to generate the mixin class with
add_<graphic>()methods is gone! 🥳 . It's replaced with elegant descriptors 😄https://github.com/fastplotlib/fastplotlib/blob/09dfc8d7d51ad97fe700c91f543aa9bf5cc326e9/fastplotlib/layouts/_graphic_methods_mixin.py
Video
config-2026-09-14_01.50.36.mp4
Didn't show in the video but obviously if a value is provided for an argument it'll use that and not the config value:
This PR is fully organic written by a human. Claude was producing completely useless garbage for this task.