Git Product home page Git Product logo

prefabclasses's Introduction

PrefabClasses - Python Class Boilerplate Generator

PrefabClasses Test Status

Warning

prefab_classes is being deprecated in favour of the prefab submodule of ducktools-classbuilder which is a mostly compatible reimplementation.

This can be obtained using:

python -m pip install ducktools-classbuilder

The only (intentional) changes in that module are:

  • SlotAttributes is now SlotFields
  • as_dict is in the main module and does not cache
  • @prefab(dict_method=True) will create a cached as_dict method on the class that the function will automatically use.
  • attributes are excluded from as_dict using the serialize argument to attribute
  • to_json no longer exists - just use json.dumps(obj, default=as_dict)

Writes the class boilerplate code so you don't have to. Yet another variation on attrs/dataclasses.

Unlike dataclasses or attrs, prefab_classes has a focus on performance and startup time in particular. This includes trying to minimise the impact of importing the module itself.

Classes are written lazily when their methods are first needed.

For more detail look at the documentation.

Usage

Define the class using plain assignment and attribute function calls:

from prefab_classes import prefab, attribute

@prefab
class Settings:
    hostname = attribute(default="localhost")
    template_folder = attribute(default='base/path')
    template_name = attribute(default='index')

Or with type hinting:

from prefab_classes import prefab

@prefab
class Settings:
    hostname: str = "localhost"
    template_folder: str = 'base/path'
    template_name: str = 'index'

In either case the result behaves the same.

>>> from prefab_classes.funcs import to_json
>>> s = Settings()
>>> print(s)
Settings(hostname='localhost', template_folder='base/path', template_name='index')
>>> to_json(s)
'{"hostname": "localhost", "template_folder": "base/path", "template_name": "index"}'

For further details see the usage pages in the documentation.

Slots

There is new support for creating classes defined via __slots__ using a SlotAttributes instance.

Similarly to the type hinted form, plain values given to a SlotAttributes instance are treated as defaults while attribute calls are handled normally. doc values will be seen when calling help(...) on the class while the __annotations__ dictionary will be updated with type values given. Annotations can also still be given normally (which will probably be necessary for static typing tools).

from prefab_classes import prefab, attribute, SlotAttributes

@prefab
class Settings:
    __slots__ = SlotAttributes(
        hostname="localhost",
        template_folder="base/path",
        template_name=attribute(default="index", type=str, doc="Name of the template"),
    )

Why not make slots an argument to the decorator like dataclasses (or attrs)?

Because this doesn't work properly.

When you make a slotted dataclass the decorator actually has to create a new class and copy everything defined on the original class over because it is impossible to set functional slots on a class that already exists. This leads to some subtle bugs if anything ends up referring to the original class (ex: method decorators that rely on the class or the super() function in a method).

from dataclasses import dataclass
from prefab_classes import prefab, SlotAttributes

cache = {}
def cacher(cls):
    cache[cls.__name__] = cls
    return cls

@dataclass(slots=True)
@cacher
class DataclassSlots:
    pass

@prefab
@cacher
class PrefabSlots:
    __slots__ = SlotAttributes()
    
print(f"{cache['DataclassSlots'] is DataclassSlots = }")  # False
print(f"{cache['PrefabSlots'] is PrefabSlots = }")  # True

By contrast, by using __slots__ ability to use a mapping as input @prefab does not need to create a new class and can simply modify the original in-place. Once done __slots__ is replaced with a dict containing the same keys and any docs provided that can be rendered via help(...).

Why not just use attrs or dataclasses?

If attrs or dataclasses solves your problem then you should use them. They are thoroughly tested, well supported packages. This is a new project and has not had the rigorous real world testing of either of those.

Prefab Classes has been created for situations where startup time is important, such as for CLI tools and for handling conversion of inputs in a way that was more useful to me than attrs converters (__prefab_post_init__).

For example looking at import time (before any classes have been generated).

Command Mean [ms] Min [ms] Max [ms] Relative
python -c "pass" 22.7 ± 0.8 21.4 24.9 1.00
python -c "from prefab_classes import prefab" 23.9 ± 1.0 23.0 27.1 1.06 ± 0.06
python -c "from collections import namedtuple" 23.6 ± 0.5 22.9 24.9 1.04 ± 0.04
python -c "from typing import NamedTuple" 31.3 ± 0.4 30.7 32.7 1.38 ± 0.05
python -c "from dataclasses import dataclass" 38.0 ± 0.5 36.9 38.9 1.68 ± 0.06
python -c "from attrs import define" 52.1 ± 0.8 50.7 54.0 2.30 ± 0.09
python -c "from pydantic import BaseModel" 70.0 ± 3.7 65.6 79.3 3.09 ± 0.20

For more detailed tests you can look at the performance section of the docs.

How does it work

The @prefab decorator analyses the class it is decorating and prepares an internals dict, along with performing some other early checks (this may potentially be deferred in a future update, do not depend on any of the prefab internals directly). Once this is done it sets any direct values (PREFAB_FIELDS and __match_args__ if required) and places non-data descriptors for all of the magic methods to be generated.

The non-data descriptors for each of the magic methods perform code generation when first called in order to generate the actual methods. Once the method has been generated, the descriptor is replaced on the class with the resulting method so there is no overhead regenerating the method on each access.

By only generating methods the first time they are used the start time can be improved and methods that are never used don't have to be created at all (for example the __repr__ method is useful when debugging but may not be used in normal runtime). In contrast dataclasses generates all of its methods when the class is created.

On using an approach vs using a tool

As this module's code generation is based on the workings of David Beazley's Cluegen I thought it was briefly worth discussing his note on learning an approach vs using a tool.

This project arose as a result of looking at my own approach to the same problem, based on extending the workings of cluegen. I found there were some features I needed for the projects I was working on (the first instance being that cluegen doesn't support defaults that aren't builtins).

This grew and on making further extensions and customising the project to my needs I found I wanted to use it in all of my projects and the easiest way to do this and keep things in sync was to publish it as a tool on PyPI.

It has only 1 dependency at runtime which is a small library I've created to handle lazy imports. This is used to provide easy access to functions for the user while keeping the overall import time low. It's also used internally to defer some methods from being imported (eg: if you never look at a __repr__, then you don't need to import reprlib.recursive_repr). Unfortunately this raises the base import time but it's still a lot faster than import typing.

So this is the tool I've created for my use using the approach I've come up with to suit my needs. You are welcome to use it if you wish - and if it suits your needs better than attrs or dataclasses then good. I'm glad you found this useful.

Credit

autogen function and some magic method definitions taken from David Beazley's Cluegen

General design based on previous experience using dataclasses and attrs and trying to match the requirements for PEP 681.

prefabclasses's People

Contributors

davidcellis avatar dependabot[bot] avatar

Stargazers

 avatar  avatar  avatar

Watchers

 avatar

prefabclasses's Issues

Take type annotations from __prefab_post_init__ for __init__ if provided

Currently if you want to convert inputs the intended method is to make a post_init function with the fields as arguments and do the conversion and assignment there. For type hinting purposes this means that the fields in the __prefab_post_init__ could be used to define more accurate hints for __init__ if type narrowing is being used. Currently the __init__ type hints are taken directly from the annotations on the class (if present).

For example:

from prefab_classes import prefab
from pathlib import Path

@prefab
class X:
    pth: Path = Path("/usr/bin/python")
    
    def __prefab_post_init__(self, pth: str | Path) -> None:
        self.pth = Path(pth)

would generate code like

from pathlib import Path

class X:
    pth: Path

    def __init__(self, pth: str | Path = Path("/usr/bin/python")) -> None:
        self.__prefab_post_init__(pth)

    ...

    def __prefab_post_init__(self, pth: str | Path) -> None:
        self.pth = Path(pth)
    

As the values are supposed to represent what the object contains rather than what the input value is this should be more accurate for type hinting purposes - at least for the compiled format.

Update actions before next release

Some features of the actions used for publishing are outdated, these should be updated before releasing the next version (after 0.10.1)

Dynamic construction for dynamic classes

Dataclasses and attrs both have easy ways of creating classes at runtime. This would obviously not work for the compiled versions of classes which are the main feature, but could be useful for dynamically creating dynamic prefabs.

Separate the import hook into its own module.

Currently the import hook is part of the prefab_classes module which means in order to import it you first end up importing all of the dynamic machinery. As compiling a prefab now removes the prefab_classes import if it is unnecessary, moving the import hook into a different module could avoid the time importing all of the dynamic code that isn't used.

Repr should not look like it can 'eval' if it is clear this is not the case

If init=False xor repr=false or if exclude_field=True on any field in a prefab then the repr will no longer correctly eval back to the original class. In these cases the repr string should no longer attempt to look like a valid call and should instead be a <prefab Name; attrib=value, ...> string.

Test on PyPy 3.10 - drop support for Python 3.9

PyPy only being up to 3.9 was the only reason this project supported Python 3.9 originally.

Some type hints were technically incorrect for python 3.9 but contained in strings to avoid syntax issues, these could now be replaced with regular type hints. (This is mostly the use of | to avoid importing from typing for unions as import typing occurring anywhere in the runtime would be a significant performance hit.)

Drop support for the compiled form of class and proceed only with dynamic

Despite the theoretical performance benefit of pre-compiled classes, their usage is still awkward. Needing to put the import hook in place and knowing that if another tool compiles the .py files to .pyc it will be ignored (ex: making a pyinstaller bundle) means that they are less useful than hoped.

Due to this I end up not using them myself so I have no real reason to give myself over twice as much work supporting them.

Replace FrozenPrefabError with a TypeError

Currently for frozen classes attempting to assign values after instance creation raises a FrozenPrefabError much like the FrozenInstanceError from dataclasses.

This is going to be changed so it raises a plain TypeError instead, much like attempting to assign to a tuple. The reason for this is that currently, in order to do this compiled classes have to import the error in order to raise it - thus adding a slow import of all of the dynamic prefab implementation. A TypeError with an appropriate message seems to fit the pattern of builtins that don't accept assignment so this seems to be the sensible pattern to copy to avoid this import.

Define and handle descriptor field behaviour

Currently descriptor fields won't work correctly. Compiled prefabs will remove any descriptor attributes as they do with all other attributes.

I think these will need to be assigned by an argument to attribute as otherwise compiled prefabs have no way to recognise them.

The implementation will be deferred until I have a use case.

Classes as default values have to be passed to the globals of the parent class.

Currently I've used a work-around to the issue of instances of classes as default values but it's definitely more of a work-around than a solution.

The problem can be seen with this example:

from prefab import Attribute, Prefab
from pathlib import Path

class Settings(Prefab):
    pth = Attribute(default=Path('blah'), converter=Path)

x = Settings()

This will throw an exception
NameError: name 'WindowsPath' is not defined

This happens when exec is called on the init function and the signature has WindowsPath('blah') in it, it attempts to evaluate and fails because WindowsPath doesn't exist in the context of the exec.

Currently the hackish solution I've used is explicitly passing the globals dict from where the module is defined.

from prefab import Attribute, Prefab
from pathlib import Path, WindowsPath

class Settings(Prefab):
    _globals = globals()
    pth = Attribute(default=Path('blah'), converter=Path)

x = Settings()

This isn't very satisfying and won't work if the object doesn't have a usable repr.

Compiled prefabs don't recognise fields defined on a separate line to the annotation.

Demo:

# COMPILE_PREFABS

from prefab_classes import prefab

@prefab(compile_prefab=True, compile_fallback=True)
class X:
    y: str
    y = "test"

Running

x = X()
print(x)

Without the compiler:

X(y='test')

With the compiler:

TypeError: X.__init__() missing 1 required positional argument: 'y'

This is because the compiled version only looks at AnnAssign in this case, it should also check Assign. This will need to check all the assignments in order to avoid differences with the dynamic version.

Create Documentation

This has grown beyond just having a readme and so could do with some proper documentation.

Match dataclass features where sensible

Logically it will be largely expected that this will 'do the same thing' as dataclasses where it makes sense. It will be documented if the module is intentionally making different decisions. There are a few obvious features that it makes sense to implement.

Intended compatibility:

  • ignore ClassVar typed objects when collecting attributes/fields
  • restore match_args for interpreted / add match_args for compiled prefabs
  • support empty classes with no attributes.
  • Don't overwrite existing dunder methods
  • KW_ONLY annotation

ClassVar and KW_ONLY support should be relatively simple.

dataclasses uses an InitVar type to indicate something that should be passed to INIT. I'm not sure that this is the route to follow, but some method of passing variables from init to prefab_post_init could be useful.

I think descriptor fields will either require a specific type annotation or an extra argument to attribute.

Frozen class behaviour might be slightly different to that of dataclasses.

Passing parameters to pre_init and post_init

Dataclasses uses a special InitVar annotation to pass values through to __post_init__.

This module is not intending to replicate this behaviour, ClassVar identification is already messy, but is intended to be used as a marker to ignore an attribute.

InitVar is specifically an instruction on how to handle an attribute and so if anything should be an argument to attribute().

The current design idea is to add 1 argument exclude_field to attribute which would exclude the field from all magic methods apart from __init__ and would pass it through to __prefab_post_init__ without assigning it in __init__.

__prefab_pre_init__ and __prefab_post_init__ would also now be able to accept any attribute name as an argument and have it passed from __init__. Arguments used in __prefab_post_init__ will not be assigned in __init__. Any names used in the pre/post init methods that are not valid attributes will raise the appropriate error on construction.

Creating classes using slots

Currently trying to make a slotted class will error as the @prefab decorator does not recognise that the value from the slotted attribute is not a default and so it is interpreted incorrectly.

In v0.11.1 this will be fixed, but it is not particularly useful as you can't use any of the attribute features or set a default value this way, and the attribute must be typed as using attribute() will be interpreted as a class attribute by Python before the @prefab decorator has a chance to modify the class and fail with a ValueError.

In a future version - once compiled prefabs have been removed - I am investigating adding a new syntax to declare prefabs with slots without the downsides of needing to recreate the class from scratch.

My current thinking is the syntax will look something like this:

@prefab
class SlottedPrefab
    __slots__ = PrefabSlots(
        x=attribute(default=42, type=int, doc="doc for x"),
    )

If the class has a __slots__ attribute and the type of the attribute is PrefabSlots it could then use the information there and ignore type hints and attributes for creation.

Once the class has been processed __slots__ will be replaced with a plain dict to allow help(...) to access any docs provided in doc attributes (which currently don't exist).

Runtime types will be placed, but this will probably not play nicely with type checkers. (Although it already doesn't work perfectly).

Remove support for converters.

Initially converters were added as I was replacing attrs but the __init__ code will be simpler with them removed. Their core functionality has been replaced by the __prefab_post_init__ function.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.