Skip to main content

A lightweight extension of attrs that adds extras like logging.

Project description

Attrsx

attrsx – An Extension to attrs for Declarative Handler Injection & Integrated Logging


1. Purpose

attrsx builds on top of the attrs data‑class library and contributes two orthogonal capabilities that are frequently needed in service‑layer and infrastructure code:

  1. Integrated Logging – transparent, boilerplate‑free creation of a configured logging.Logger for every instance.
  2. Declarative Handler Injection – generation of lazy‑initialised strategy objects (“handlers”) declared via handler_specs.

Both features are opt‑in; if a class does not request them, attrsx.define behaves exactly like attrs.define.


2. Key Features

  • Drop-in replacement for attrs.define – forwards every keyword parameter that attrs.define understands (slots, frozen, kw_only, validators, converters, …).

  • Integrated logging – injects four fields (logger, loggerLvl, logger_name, logger_format) and wraps __attrs_post_init__ to attaches a logging.logger to the instance if none is present. The logger’s name defaults to the class name; level and format are configurable per instance.

  • Declarative handler injection – for each entry in handler_specs={"key": HandlerClass, ...} the decorator adds three fields
    (<key>_class, <key>_params, <key>_h) plus a lazy factory method _initialize_<key>_h(), which calls
    <key>_class(**<key>_params) the first time it is invoked.

  • Type-hint compliance – all generated attributes are inserted into __annotations__, so static type-checkers recognise them.

  • Zero runtime overhead – field and method generation is performed once, at import time; normal instance construction stays on the attrs fast path.


3. Underlying Pattern

The handler mechanism realises Declarative Handler Injection (a variant of the Strategy pattern implemented through composition and a lazy factory method). Configuration is expressed as data, leaving the host class agnostic of concrete strategy classes.


4. When to Use

  • You need consistent, ready‑to‑use logging across many small classes.
  • You want to supply interchangeable helper or strategy objects without manual glue code.
  • You prefer composition over inheritance but dislike factory boilerplate.

If none of the above apply, simply continue to use attrs.define; the migration path is one import statement.

import attrsx
import attrs

Usage

1. Built-in logger

One of the primary extensions in attrsx is automatic logging. It can be accessed via self.logger in any attrsx-decorated class.

Basic Logger Usage

@attrsx.define
class ProcessData:
    data: str = attrs.field(default=None)

    def run(self):
        self.logger.info("Running data processing...")
        self.logger.debug(f"Processing data: {self.data}")
        return f"Processed: {self.data}"
ProcessData(data = "data").run()
INFO:ProcessData:Running data processing...





'Processed: data'

Logger Configuration

The logging behavior can be customized using the following optional attributes:

  • loggerLvl : Sets the log level (from logging), defaults to logging.INFO.
  • logger_name : Specifies the logger name; defaults to the class name.
  • logger_format : Sets the logging message format, defaults to %(levelname)s:%(name)s:%(message)s.

self.logger becomes available starting from __attrs_post_init__.

import logging

@attrsx.define
class VerboseProcess:
    data: str = attrs.field(default=None)
    loggerLvl: int = attrs.field(default=logging.DEBUG)
    logger_format: str = attrs.field(
        default="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
    )

    def __attrs_post_init__(self):
        self.logger.info("Custom post-init logic")
        self.data = "DATA"

    def run(self):
        self.logger.debug(f"Processing {self.data}")
        self.logger.debug(dline=True)
        return f"Processed: {self.data}"
VerboseProcess(data = "data").run()
2025-12-29 20:36:10,019 - VerboseProcess - INFO - Custom post-init logic
2025-12-29 20:36:10,020 - VerboseProcess - DEBUG - Processing DATA
2025-12-29 20:36:10,020 - VerboseProcess - DEBUG - 





'Processed: DATA'

Using External Loggers

Non standard loggers can enable additional functionality and normally ignored inputs that would not break normal logger, when attrsx is used.

@attrsx.define
class ExtraLogger:

    def info(self, msg):
        self.logger.info(msg)

    def debug(self, msg = None, dline = False, *args, **kwargs):
        if dline is True:
            msg = "=" * 50
        self.logger.debug(msg, *args, **kwargs)

An external, pre-initialized logger can also be provided to the class using the logger attribute.

extra_logger = ExtraLogger(loggerLvl=logging.DEBUG)

VerboseProcess(
    data = "data",
    logger = extra_logger
).run()
INFO:ExtraLogger:Custom post-init logic
DEBUG:ExtraLogger:Processing DATA
DEBUG:ExtraLogger:==================================================





'Processed: DATA'

2. Built-in handlers

Another extension in attrsx is built-in handlers. This feature is meant to help plug interchangeable helper objects (“handlers”) into a host class declaratively, without manual wiring, in a way that allows for both providing initialized handlers as well as initializing handlers within a class.

The main class has access to methods of handler classes, can reinitialize them or reset them in a well defined way, where most additional code is added automatically by the library to the class.

Adding handlers to a class

To add handlers to an attrsx class, one can take advantage of handler_specs parameter within @attrsx.define, which takes a dictionary, where key is alias for the handler and value is the handler class.

@attrsx.define(handler_specs={"procd": ProcessData})
class Service:
    def run(self, data: str):
        self.logger.info("Calling procd handler")
        self._initialize_procd_h(uparams={"data": data})
        return self.procd_h.run()
Service().run("some data")
INFO:Service:Calling procd handler
INFO:ProcessData:Running data processing...





'Processed: some data'

For each handler in provided via handler_specs in definition of NewClass as :

@attrsx.define(handler_specs = {
    'handler_alias' : HandlerClass, ..., 
    'another_handler_alias_n' : AnotherHandlerClass})
class NewClass:
    ...

the class gets the following attributes:

  • {handler_alias}_h : an instance of the handler, by default set to None
  • {handler_alias}_class : a class of the handler, will be used if corresponding instance is None, when initialized
  • {handler_alias}_params : parameters that should be used for creating new instance of the handler, using handler class

and a function:

def _initialize_{handler_alias}_h(self, params : dict = None, uparams : dict = None):

    if params is None:
        params = self.{handler_alias}_params

    if uparams is not None:
        params.update(uparams)

    if self.{handler_alias}_h is None:
        self.{handler_alias}_h = self.{handler_alias}_class(**params)

which checks is initialized instance was already provided and if not, initializes handler with provided parameters.

To achieve the same with regular attrs, the NewClass could be defined in the following way, which would work exactly the same:

@attrs.define
class NewClass:
    ...

    handler_alias_h = attrs.field(default=None)
    handler_alias_class = attrs.field(default=HandlerClass)
    handler_alias_params = attrs.field(default={})

    another_handler_alias_n_h = attrs.field(default=None)
    another_handler_alias_n_class = attrs.field(default=AnotherHandlerClass)
    another_handler_alias_n_params = attrs.field(default={})

    logger_chaining = attrs.field(default={
        'loggerLvl' : True, 
        'logger' : False, 
        'logger_format' : True})

    def _apply_logger_chaining(self, handler_class, params):

        if self.logger_chaining.get("logger"):
            if ('logger' in handler_class.__dict__) \
                    and "logger" not in params.keys():
                params["logger"] = self.logger

        if self.logger_chaining.get("loggerLvl"):

            if ('loggerLvl' in handler_class.__dict__) \
                    and "loggerLvl" not in params.keys():
                params["loggerLvl"] = self.loggerLvl

        if self.logger_chaining.get("logger_format"):

            if ('logger_format' in handler_class.__dict__) \
                    and "logger_format" not in params.keys():
                params["logger_format"] = self.logger_format

        return params

    def _initialize_handler_alias_h(self, params : dict = None, uparams : dict = None):

        if params is None:
            params = self.handler_alias_params

        if uparams is not None:
            params.update(uparams)

        params = self._apply_logger_chaining(
            handler_class = self.handler_alias_class, 
            params = params)

        if self.handler_alias_h is None:
            self.handler_alias_n_h = self.handler_alias_class(**params)

    def _initialize_another_handler_alias_n_h(self, params : dict = None, uparams : dict = None):

        if params is None:
            params = self.another_handler_alias_n_params

        if uparams is not None:
            params.update(uparams)

        params = self._apply_logger_chaining(
            handler_class = self.another_handler_alias_n_class, 
            params = params)

        if self.another_handler_alias_n_h is None:
            self.another_handler_alias_n_h = self.another_handler_alias_n_class(**params)
    

Setting default parameters

For each handler there is {handler_alias}_params within new class, which can be used to provide parameters for handler initialization.

Sometimes there is a need to extend or update default parameters and initialize/reinitialize the handler. Each handler has _initialize_{handler_alias}_h method within new class to which new default params (parameters that one would use when initializing handler class) could be passed via params and update to these or {handler_alias}_params via uparams.

@attrsx.define(handler_specs = {'procd' : ProcessData})
class Service:
    data: str = attrs.field(default=None)

    procd_params = attrs.field(default={"loggerLvl" : logging.DEBUG})

    def run(self, data : str):

        self.logger.info("Running method from procd handler!")

        self._initialize_procd_h(uparams={"data" : data})

        return self.procd_h.run()
Service().run(data = "some data")
INFO:Service:Running method from procd handler!
INFO:ProcessData:Running data processing...
DEBUG:ProcessData:Processing data: some data





'Processed: some data'

Adding handler initialization to class post init

One of the benefits of using attrs is the ability to define what happens when class in initialized without making the whole __init__, by using __attrs_post_init__. Some handlers could be added there to be initialized with a new class and rdy to be used within its methods.

@attrsx.define(handler_specs = {'procd' : ProcessData})
class Service:
    data: str = attrs.field(default=None)

    procd_params = attrs.field(default={"data" : "default data"})

    def __attrs_post_init__(self):
        self._initialize_procd_h()

    def run(self, data : str = None):

        self.logger.info("Running method from procd handler!")

        return self.procd_h.run()
Service().run()
INFO:Service:Running method from procd handler!
INFO:ProcessData:Running data processing...





'Processed: default data'

Using instances of handlers initialized outside of new class

Each new class defined with handler_specs can use initialized instances of handlers and skip initialization within new class, which allows the code to remain flexible.

@attrsx.define(handler_specs = {'procd' : ProcessData})
class Service:
    data: str = attrs.field(default=None)

    procd_params = attrs.field(default={"data" : "default data"})

    def __attrs_post_init__(self):
        self._initialize_procd_h()

    def run(self, data : str = None):

        self.logger.info("Running method from procd handler!")

        return self.procd_h.run()
outside_procd = ProcessData(data = 'some other data')

Service(procd_h=outside_procd).run()
INFO:Service:Running method from procd handler!
INFO:ProcessData:Running data processing...





'Processed: some other data'

Chaining loggers

Each attrsx class has its own independent built-in logger, it might be useful to control behaviour of handler loggers from main class (for handlers that themselves are attrsx classes). This package allows to chain loggers of attrsx classes on 3 different levels via logger_chaining boolean parameters in @attrsx.define:

  1. logger_format : synchronizes logger format for all attrsx handlers (by default set to True)
  2. loggerLvl : synchronizes logger level for all attrsx handlers (by default set to True)
  3. logger : uses logger defined for main class within handlers (by default set to False)
@attrsx.define(handler_specs = {'procd' : ProcessData})
class ChainedService:
    data: str = attrs.field(default=None)

    procd_params = attrs.field(default={"data" : "default data"})

    loggerLvl = attrs.field(default=logging.DEBUG)
    logger_format = attrs.field(default="%(levelname)s - %(name)s - %(message)s")

    def __attrs_post_init__(self):
        self._initialize_procd_h()

    def run(self, data : str = None):

        self.logger.info("Running method from procd handler!")

        return self.procd_h.run()
ChainedService().run()
INFO - ChainedService - Running method from procd handler!
INFO - ProcessData - Running data processing...
DEBUG - ProcessData - Processing data: default data





'Processed: default data'
@attrsx.define(handler_specs = {'procd' : ProcessData}, logger_chaining={'logger' : True})
class ChainedService:
    data: str = attrs.field(default=None)

    procd_params = attrs.field(default={"data" : "default data"})

    loggerLvl = attrs.field(default=logging.DEBUG)
    logger_format = attrs.field(default="%(levelname)s - %(name)s - %(message)s")

    def __attrs_post_init__(self):
        self._initialize_procd_h()

    def run(self, data : str = None):

        self.logger.info("Running method from procd handler!")

        return self.procd_h.run()
ChainedService().run()
INFO - ChainedService - Running method from procd handler!
INFO - ChainedService - Running data processing...
DEBUG - ChainedService - Processing data: default data





'Processed: default data'

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

attrsx-0.2.1.tar.gz (687.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

attrsx-0.2.1-py3-none-any.whl (746.4 kB view details)

Uploaded Python 3

File details

Details for the file attrsx-0.2.1.tar.gz.

File metadata

  • Download URL: attrsx-0.2.1.tar.gz
  • Upload date:
  • Size: 687.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.10.19

File hashes

Hashes for attrsx-0.2.1.tar.gz
Algorithm Hash digest
SHA256 62a6b8f7c79818bd3144bc43912f736ec80758ecf2202fe2ae3aeaa5ad766354
MD5 7acc4e329fc798690dfc1b0b2e4f1d75
BLAKE2b-256 fb35f98040ac7a99e0d332e4dd34543238beb99985c4ea2bde7d5dcbf7bf803c

See more details on using hashes here.

File details

Details for the file attrsx-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: attrsx-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 746.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.10.19

File hashes

Hashes for attrsx-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 561690a19955583bbcb6944892511261bab3c39f9edbd3c9162b3bb3385e3584
MD5 d9d24865b7e7be54546675675f2a8cba
BLAKE2b-256 48e322555976f5644554de731adbe3a242050a2db947dc0a2a8dad4a7ce16020

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page