Skip to content

config system - #1080

Merged
kushalkolar merged 26 commits into
ndwidgetfrom
config-system
Sep 15, 2026
Merged

kushalkolar merged 26 commits into
ndwidgetfrom
config-system

Conversation

@kushalkolar

@kushalkolar kushalkolar commented Sep 13, 2026

Copy link
Copy Markdown
Member

An actually maintainable config system.

  • screenshot test (should be simples, I did some manual testing already)
  • populate the default preset by converting all methods to dicts and store that dict
  • implement on collections and use .pyi file so IDEs provide useful info
  • docs page or section in user guide
  • implement for the add_<graphic> methods by using fancie descriptor logic
  • Some axes label improvement stuff which got committed to this branch

User API

fpl.LineGraphic.config.init.colors = "b"
fpl.layouts.Subplot.config.init.background_color = "w"

fpl.layouts.Subplot.config.init.toolbar = False

Some presets:

fpl.style.light()
fpl.style.compact()
fpl.style.flynn()

Presets can be stacked, they "merge" into each other, so fpl.presets.light() followed by fpl.presets.compact() will apply both. This of course has to be done sensibly, fpl.presets.light() followed by fpl.presets.dark() just reverts things. Each preset just sets config values, that's all.

Registering a class to the config system

@global_config.register
class Figure:
    config = global_config.descriptor
    @global_config.set(size=(900, 700)
    def __init__(self):
        pass

# Subclass doesn't have to declare @config.set for 
# any methods that are only defined in the parent class
# so this will work and it'll use the same configured `size` as Figure
@global_config.register
def ImguiFigure(Figure):
    ...


# all graphic subclasses get access since only the 
# base class has to declare the config class variable
class Graphic:
    config = global_config.descriptor


# still have to register all subclasses into the configuration though
@global_config.register
class LineGraphic(Graphic):
    @global_config.declare("colors", "thickness")
    def __init__(
        self,
        data,
        colors: str | tuple[float, float, float] = "w",
        thickness: float = 2.0,
        cmap: str | None = None,
    ):
        print(data, colors, thickness, cmap)
        print(LineGraphic.config)

To summarize

Rules:

  1. A class must be registered with @global_config.register
  2. A class must have the class attribute with the name config set to global_config.descriptor
  3. A registered class must have at least one method with @global_config.set on them, or its parent classes must have them.
  4. All subclasses of a registered class must also be explicitly registered with @global_config.register, however if this subclass is overriding methods in the parent class which have @global_config.set I don't think it has to be registered but we don't have a test case for this in fastplotlib AFAIK.
  5. The default value in the method signature is used as the default config value.
  6. Any method that is not called with an explicit value, e.g. add_line(data=...) will inject the colors from the global_config, if we do add_line(data=..., colors="r") the explicitly provided colors="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:

  • A subclass that is registered but that does not explicitly mark a method with @global_config.set uses its parent's config values. For example ImguiFigure is registered, but the ImguiFigure constructor has no @global_config.set so 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.register decorator 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.config is a dataclass so that you can tab complete, and then LineGraphic.config.init itself is also a dataclass with each argument-value pair for that method, so users can just do LineGraphic.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-settings

Also 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 pickle uses 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:

image

This PR is fully organic written by a human. Claude was producing completely useless garbage for this task.

@kushalkolar kushalkolar changed the title basic scaffold done config system Sep 13, 2026
@kushalkolar
kushalkolar marked this pull request as ready for review September 14, 2026 05:42
Comment thread fastplotlib/utils/_config.py Outdated
@kushalkolar

Copy link
Copy Markdown
Member Author

@clewis7 r4r

This looks large but the main contribution is utils/_config.py: https://github.com/fastplotlib/fastplotlib/pull/1080/changes#diff-708bbac7e717325519dfb235a4f11989f227ac3f14a29f734280f06d0a8871b7

The rest is is just adding the decorators and stuff to register each class relevant method.

Comment thread fastplotlib/utils/_style.py


class Graphic:
config = global_config.descriptor

@kushalkolar kushalkolar Sep 14, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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 😂

Comment on lines +31 to +47
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")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@clewis7 See comment on base Graphic if you're wondering why this exists

@kushalkolar

kushalkolar commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

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 __defaults__

@FlynnOConnell

Copy link
Copy Markdown
Collaborator

I absolutely love this

image

Comment on lines +273 to +332
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@clewis7 fanciest decorator I've written 😂 (updated version which is even more elegant)

@kushalkolar

Copy link
Copy Markdown
Member Author

Graphic methods generated mixin is gone and replaced with descriptors 🥳

image

@kushalkolar

Copy link
Copy Markdown
Member Author

Merging, @clewis7 has agreed to fast-track this PR.

@kushalkolar
kushalkolar merged commit 4473382 into ndwidget Sep 15, 2026
@kushalkolar
kushalkolar deleted the config-system branch September 15, 2026 22:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants