Object Tracker
A pure python object change & history tracker. Monitor all changes in your object's lifecycle and trigger callback functions to capture them.
$ pip install object-tracker
Tested for python 3.7
and above.
Key Features
- Determine if a python object has changed during it's lifecycle.
- Investigate change history through the structured changelog.
- Trigger callback functions whenever an attribute has changed.
- Simple and structured API.
- Queryable change history log.
Table of Contents :
Basic Usage
Inherit the ObjectTracker
class to create a trackable object.
from object_tracker import ObjectTracker
def observer(attr, old, new):
print(f"Observer : {attr} -> {old} - {new}")
class User(ObjectTracker):
def __init__(self, name) -> None:
ObjectTracker.__init__(self, observers=[observer,])
self.name = name
user = User("A")
print(user.tracker.changed())
# False
user.name = "B" # observers will be triggered
# Observer : name -> A - B
print(user.tracker.changed())
# True
To use the tracker without inheriting - read this guide
Getting Started
The ObjectTracker
class implements __setattr__
and tracks change history. Any object that needs to be tracked must inherit ObjectTracker
.
Go back to the table of contents
How does it work?
The object_tracker module consists of 2 major classes -
ObjectTracker
class An inheritable class that implements the __setattr__
methods and reports changes to the Tracker
class that's initialised inside it.
from object_tracker import ObjectTracker
class TrackerObject(ObjectTracker):
def __init__(self, name) -> None:
ObjectTracker.__init__(self)
pass
-
It adds a
tracker
attribute to the subclass and can be accessed byself.tracker
. -
Don't forget to initialise call it's
__init__
. You can define various parameters, see the configuration guide -
See further implementation details in
object_tracker/wrapper.py
.
Tracker
class This object is initialised inside the ObjectTracker
and does all the heavylifting ie. storing change history and checking if any change has occured. Can be accessed through the tracker
attribute when inheriting ObjectTracker
.
Note - The **kwargs
passed to ObjectTracker
are passed down to the Tracker
instance to initialise it.
class User(ObjectTracker):
def __init__(self, name) -> None:
ObjectTracker.__init__(self, observers=[observer,])
self.name = name
user = User("A")
user.name = "B" # o
print(user.tracker.changed())
- See further implementation details in
object_tracker/tracker.py
.
Go back to the table of contents
Tracker API
When an object has inherited ObjectTracker
, it is now a trackable object. You can access the Tracker
instance by using the self.tracker
attribute of your trackable object.
You can also use a standalone instance of Tracker
, with some caveats - read more here
Configuration
There are a bunch of config variables that can be modified when inheriting the ObjectTracker
class:
Note - The **kwargs
passed to ObjectTracker
are passed down to the Tracker
instance to initialise it.
-
auto_notify
- defaultTrue
- Autmatically notifies observers everytime an attribute is set. Can be set toFalse
and called manually usingnotify_observers(self, attr, old, new)
-
ignore_init
- defaultTrue
- Ignore changes made from__init__
functions. These will not be pushed to the changelog or be notified. -
log
-> An instance ofQueryLog
, stores a structured log and exposes a query interface to object history. Read more about it here -
observers, observable_attributes, attribute_observer_map
-> Read more about adding observers
from object_tracker import ObjectTracker
def observer(attr, old, new):
print(f"Observer : {attr} -> {old} - {new}")
class User(ObjectTracker):
def __init__(self, name) -> None:
ObjectTracker.__init__(self, observers=[observer,], auto_notify=False)
self.name = name
Go back to the table of contents
Track object change
changed(obj=None)
checks if any attribute has changed, whereas tracker.attribute_changed(attr, obj=None)
checks if a single attribute has changed. The obj
argument is only needed when using a standalone instance of Tracker
instead of inheriting ObjectTracker
, read more here
user = User("A")
print(user.tracker.changed())
# False
user.name = "B"
print(user.tracker.changed())
# True
print(user.tracker.attribute_changed('name'))
# True
print(user.tracker.attribute_changed('age'))
# False
Go back to the table of contents
History
Each Tracker
object has a structured change history log - self.log
- for all the attributes, an instance of QueryLog
.
The QueryLog
object maintains 2 lists, a log
of every change and a query buffer
for temporary storage while querying
Both lists carry instances of Entry
, a structured log record containing
-
attr
- String representation of the attribute that was modified -
old
- Old value of the attribute -
new
- New value of the attribute -
timestamp
- An instance ofdatetime.datetime
Every change is implicitly pushed - push(attr, old, new)
- to the QueryLog
instance.
The log/history instance can be accessed by self.history
or self.log
user = User("A")
user.name = "B"
user.tracker.print()
user.tracker.history.print()
history = user.tracker.history.fetch()
print(history)
Go back to the table of contents
Querying change history
The QueryLog
class offers a simple query interface to filter logs -
Terminal methods (do not chain ie. return self
) -
-
fetch(self)
- returns the current query buffer -
flush(self)
- Flushes the entire query buffer -
count(self)
- Counts the number of log entries
Chaned methods (return self
) -
-
filter(self, attrs)
- Accepts an optional attribute string OR list of attribute strings, and filters out their logs. -
exclude(self, atrrs)
- Accepts an optional attribute string OR list of attribute strings, and excludes their logs from the query buffer
The QueryLog
instance can be accessed by tracker.history
or tracker.log
class User(ObjectTracker):
def __init__(self, name, age) -> None:
super().__init__()
self.name = name
self.age = age
user = User("A", 20)
user.name = "B"
user.age = 50
user.tracker.history.print()
# [{'attr': 'name', 'old': 'A', 'new': 'B', 'timestamp': datetime.datetime(2023, 3, 15, 15, 4, 52, 583628)}, {'attr': 'age', 'old': 20, 'new': 50, 'timestamp': datetime.datetime(2023, 3, 15, 15, 4, 52, 583665)}]
print(user.tracker.history.count())
# 2
name_history = user.tracker.history.filter('name').fetch()
print(name_history)
# [{'attr': 'name', 'old': 'A', 'new': 'B', 'timestamp': datetime.datetime(2023, 3, 15, 15, 4, 52, 583628)}]
print(user.tracker.history.filter('name').count())
# 1
print(user.tracker.history.exclude('name').count())
# 1
user.tracker.history.filter('age').flush()
print(user.tracker.history.count())
# 1
user.tracker.history.flush()
print(user.tracker.history.count())
# 0
Go back to the table of contents
Adding observers
Observer fn signature -
def observer(attr, old, new)
You can set observer functions that will be triggered whenever a change takes place for an attribute
from object_tracker import ObjectTracker
def observer(attr, old, new):
print(f"Observer : {attr} -> {old} - {new}")
class User(ObjectTracker):
def __init__(self, name) -> None:
ObjectTracker.__init__(self, observers=[observer,])
self.name = name
user = User("A")
print(user.tracker.changed())
# False
user.name = "B" # observers will be triggered
# Observer : name -> A - B
print(user.tracker.changed())
# True
attribute_observer_map
- default {}
- This is a dictionary of attribute strings mapped to a list of observer functions
that will be called whenever a change takes place on that specific attribute.
Note - The **kwargs
passed to ObjectTracker
are passed down to the Tracker
instance to initialise it.
def observer_a(attr, old, new):
print(f"Observer A: {attr} -> {old} - {new}")
def observer_b(attr, old, new):
print(f"Observer B: {attr} -> {old} - {new}")
class User(ObjectTracker):
def __init__(self, name) -> None:
attribute_observer_map = {
'name': [observer_a, observer_b],
'age': [observer_a,]
}
ObjectTracker.__init__(self,attribute_observer_map=attribute_observer_map)
self.name = name
self.age
When attribute_observer_map
is empty, then the observers
list (default []
) is used.
def observer_a(attr, old, new):
print(f"Observer A: {attr} -> {old} - {new}")
def observer_b(attr, old, new):
print(f"Observer B: {attr} -> {old} - {new}")
class User(ObjectTracker):
def __init__(self, name) -> None:
ObjectTracker.__init__(self, observers=[observer_a, observer_b])
self.name = name
- You can set a list of
observable attributes
(default[]
), and theobservers
will only be called when there is a change in one of those attributes.
from object_tracker import ObjectTracker
def observer(attr, old, new):
print(f"Observer : {attr} -> {old} - {new}")
class User(ObjectTracker):
def __init__(self, name) -> None:
ObjectTracker.__init__(self, observers=[observer,], observable_attributes=['name',])
self.name = name
self.age = age
Go back to the table of contents
Using a standalone Tracker instance ie. No inheritance
It is possible to use a standalone instance of the Tracker
class, by setting a special initial_state
attribute. Eg -
from object_tracker import Tracker
class UntrackedUser:
def __init__(self, name, age) -> None:
self.name = name
self.age = age
user = UntrackedUser("A", 100)
tracker = Tracker(initial_state=user)
print(tracker.changed(user))
# False
user.name = "B"
print(tracker.changed(user))
# True
Caveats -
-
changed(obj=None)
ANDattribute_changed(obj=None)
have to be called with an object passed as argument. Otherwise you will get False results. -
The
Tracker
object has to contain theinitial_state
of the object you intend to track, otherwise callingchanged(obj)
orattribute_changed(obj)
will raise aInitialStateMissingException
-
The standalone instance DOES NOT use the
QueryLog
object, hence the change tracker fully depends on the difference of initial_state and the current object's__dict__
representation. Hence there is no history to query ie. It will be empty always.
Go back to the table of contents
Tests
Run this command inside the base directory to execute all tests inside the tests
folder:
$ python -m unittest -v
Go back to the table of contents
Release notes
- Latest -
v1.0.0
View object-tracker's detailed release history.
Go back to the table of contents
License
Copyright (c) Saurabh Pujari
All rights reserved.
This source code is licensed under the BSD-style license found in the LICENSE file in the root directory of this source tree.