Persistence¶
Sometimes, you need to schedule timed events over a long period of time; too
long to trust that a single process will stay running. Fritter includes a
module, fritter.persistence.json
, that can help you do just that.
Just Call Something In The Future: Reminder Example¶
The most basic thing that we can do with a persistent task is remind the user to do sommething at some point in the future. So let’s start off by building that.
Whever building anything persistent it is important to establish what objects
are safe to serialize and how to serialize them in this context. To register
our serializable objects, we will use a JSONRegistry
, so let’s instantiate one.
registry = JSONRegistry[object]()
Don’t worry about the [object]
there just yet; it tells us the type of the
“bootstrap” for this registry. We’ll get to that later.
Next, we’ll make a Reminder class, which just holds a bit of text to remind us about.
@dataclass
class Reminder:
text: str
To make this class serializable by our JSON serializer, we have to add a few instance and class methods to conform to its required Protocol:
a
typeCodeForJSON
classmethod to provide a type-code string that uniquely identifies this class within the context of this specificJSONRegistry
instance, which defines our serialization format.an
toJSON
instance method to serialize it to a JSON-serializable dict, anda
fromJSON
method that passes in the result of thattoJSON
method as well as some other parameters.
Here are those implementations:
@classmethod
def typeCodeForJSON(cls) -> str:
return "reminder"
def toJSON(
self,
registry: JSONRegistry[object],
) -> dict[str, object]:
return {"text": self.text}
@classmethod
def fromJSON(
cls,
load: LoadProcess[object],
json: JSONObject,
) -> Reminder:
return cls(json["text"])
To complete this object, we need the actual method which we will be scheduling
to run in the future. In order to mark a method as serializable by the
scheduler, we define a 0-argument, None
-returning method and decorate it
with registry.method
from the JSONRegistry
that we instantiated before.
@registry.method
def show(self) -> None:
print(f"Reminder! {self.text}")
Now, we need to schedule the reminder. For that, we’ll have a function that
schedules our show
method with a given scheduler, which takes:
- a scheduler,
- some number of seconds into the future, and
- a message to show.
We’ll instantiate a Reminder
, and create a datetype.DateTime
with a
ZoneInfo
time zone.
Note
In order to serialize time zone information, we need a common method of
identifying the zone, and a consistent type for using. To ensure this,
Fritter uses datetype
’s
type-wrapper for datetime.datetime
. This is purely for type-checking;
at runtime, these objects are datetime.datetime
instances. The
JSON serializer also requires zoneinfo.ZoneInfo
objects
specifically as the tzinfo
, because it will be serialized by its key
attribute. Other sorts of tzinfo
objects, like datetime.timezone
, do
not have this attribute and cannot be reliably serialized. Mypy should
alert you to any type mismatches here, so you don’t need to memorize this,
but that’s why we are using datetype
here.
Since the user probably wants their reminders scheduled in their own time
zone, Fritter provides a convenience function, guessLocalZone
, which uses platform-specific
heuristics to determine the local machine’s IANA timezone identifier and use
that.
def remind(
scheduler: JSONableScheduler[dict[Any, Any]],
seconds: int,
message: str,
) -> None:
now = DateTime.now(guessLocalZone())
later = now + timedelta(seconds=seconds)
work = Reminder(message).show
scheduler.callAt(later, work)
Next, when we run our script, we always want to load up the scheduler from the
file where it is saved, if that file is there, and let it run for a little
while to take care of any pending work before we do anything else. We can
create a SleepDriver
and use
our JSONRegistry
’s load
method, then block
with a short timeout before
returning the loaded scheduler. We will then run some code to update the
scheduler, maybe adding some stuff to it, then save it again with any completed
calls removed and any new calls added.
Fritter provides a function,
fritter.persistent.jsonable.schedulerAtPath()
, which does most of this
work for you, returning a contextmanager that either loads or creates a
Scheduler
.
def runScheduler(newReminder: tuple[int, str] | None) -> None:
bootstrap: dict[Any, Any] = {}
with schedulerAtPath(
registry,
DateTimeDriver(driver := SleepDriver()),
Path("saved-schedule.json"),
bootstrap,
) as sched:
driver.block(1.0)
if newReminder:
newTime, message = newReminder
remind(sched, newTime, message)
Now to put all of that together, we’ll look at the command-line. If the user specifies any arguments, the first should be an integer number of seconds, and the rest of the command line is the message we want to get reminded of. Otherwise, just run the scheduler to catch up to the current time.
if __name__ == "__main__":
args = sys.argv[1:]
reminder = None if not args else (int(args[0]), " ".join(args[1:]))
runScheduler(reminder)
And that’s it! On the command line, you can set yourself some reminders. Now, in a real application, you’d probably want significant amounts of time to pass, and run the script every day, or at most every few hours, to check on your reminders. But to simulate that for a quick example, here’s a little shell script that will set one reminder at 5 seconds, another at 10, then wait for them each to trigger:
python docs/json_basic_reminder.py 5 hello &&
python docs/json_basic_reminder.py 10 goodbye &&
echo 'one...' &&
sleep 6 &&
python docs/json_basic_reminder.py &&
echo 'two...' &&
sleep 6 && python
docs/json_basic_reminder.py
which will produce output that looks like this:
one...
Reminder! hello
two...
Reminder! goodbye
With the techniques that we reviewed in the section above, you should now be
able to write a class whose methods can be scheduled with a persistent
scheduler via schedulerAtPath
.
Next, we will move on to a slightly more complex application with more interactions.
Adding Recurrences And Counts: Friendminder Example¶
Saving and loading scheduled calls with a bit of associated state is nice, but not generally useful if we can’t save the relationships between those bits of associated state. So next, we’ll build a little application that can help you keep in touch with friends.
It’s all too easy to just forget to send a message to keep in touch every so often, so let’s make a tool to remind ourselves.
In this tool, we want to:
have a list of friends that we can add to when we want to add more people to be reminded about,
be reminded to send a message to one of those friends each week, cycling through that list.
be reminded to get in touch with each friend on their birthday each year.
This means we have two kinds of repeating call; the general “get in touch”
reminder, which would need to be a method on some shared object that can
reference the full list of friends as we move from one to the next, as well as
the birthday-specific reminder, which should probably be a method on a
Friend
class itself.