pobblebonk 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ _docs/
2
+ _proc/
3
+ .venv/
4
+ __pycache__/
5
+ .ipynb_checkpoints/
6
+ *.egg-info/
7
+ sidebar.yml
8
+ .pytest_cache/
9
+ .kosha/
@@ -0,0 +1,234 @@
1
+ Metadata-Version: 2.5
2
+ Name: pobblebonk
3
+ Version: 0.0.1
4
+ Summary: the clock and the notebook for an agent: schedules, lists and notes on honker, in one file
5
+ Project-URL: Repository, https://github.com/vedicreader/pobblebonk
6
+ Project-URL: Documentation, https://vedicreader.github.io/pobblebonk/
7
+ Author-email: Karthik <karthik.rajgopal@hotmail.com>
8
+ License: Apache-2.0
9
+ Keywords: acp,agents,cron,honker,nbdev,reminders,scheduler,sqlite
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: fastcore>=2.2.14
20
+ Requires-Dist: honker>=0.5.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # pobblebonk
24
+
25
+
26
+ <!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->
27
+
28
+ > the clock and the notebook for an agent
29
+
30
+ `pobblebonk` adds callbacks, durable lists, and per-reader notes to [honker](https://github.com/russellromney/honker). Schedules, queued work, list items, retries, and notes share one SQLite file.
31
+
32
+ There is no scheduler daemon. Call [`Pob.tick()`](https://vedicreader.github.io/pobblebonk/core.html#pob.tick) from cron, launchd, or a systemd timer. Each tick asks honker for due fires and runs their callbacks.
33
+
34
+ ## Install
35
+
36
+ ``` sh
37
+ uv add pobblebonk
38
+ ```
39
+
40
+ Python 3.12 or later is required.
41
+
42
+ ## Schedule a callback
43
+
44
+ Register a callback, add its schedule, then call `tick`. The callback return value becomes a note.
45
+
46
+ ``` python
47
+ import time
48
+
49
+ pob = Pob()
50
+ beats = []
51
+
52
+ @pob.on('heartbeat')
53
+ def beat(fire):
54
+ beats.append(fire.fire_at)
55
+ return f'beat {len(beats)} at {fire.fire_at}'
56
+
57
+ pob.add('heartbeat', every='1s')
58
+ for _ in range(3):
59
+ time.sleep(1.05)
60
+ pob.tick()
61
+
62
+ beats
63
+ ```
64
+
65
+ `fire.fire_at` is the scheduled boundary, not the time the callback happened. The one-second gaps show that the cadence held.
66
+
67
+ ``` python
68
+ [b-a for a, b in zip(beats, beats[1:])]
69
+ ```
70
+
71
+ `drain` returns the notes a reader has not seen. Each reader has an independent cursor.
72
+
73
+ ``` python
74
+ pob.drain('leela').attrgot('body')
75
+ ```
76
+
77
+ ``` python
78
+ pob.drain('leela'), len(pob.drain('phone'))
79
+ ```
80
+
81
+ ## Run ordinary Python
82
+
83
+ A callback is a Python function. It can call a library, write a file, or run a command.
84
+
85
+ ``` python
86
+ import subprocess
87
+
88
+ pob2 = Pob()
89
+
90
+ @pob2.on('git roundup')
91
+ def roundup(fire):
92
+ out = subprocess.run(['git', 'log', '--oneline', '--since=30 days ago'],
93
+ capture_output=True, text=True).stdout.strip().splitlines()
94
+ return f'{len(out)} commits in the last 30 days'
95
+
96
+ pob2.add('git roundup', cron='0 17 * * *') # 5pm daily
97
+ time.sleep(1.05)
98
+ pob2.tick(at=int(time.time()) + 86400).ran[0].result
99
+ ```
100
+
101
+ ## Give a callback a durable list
102
+
103
+ `push` adds an item to a named list. A schedule with `needs` runs only when that list is not empty. Its callback receives the open items as `fire.list`.
104
+
105
+ The callback marks its items used when it returns. If it raises, the items remain open for the retry. A `key` makes repeated open items idempotent.
106
+
107
+ ``` python
108
+ pob3 = Pob()
109
+
110
+ @pob3.on('cart')
111
+ def cart(fire):
112
+ return 'added: ' + ', '.join(i.text for i in fire.list)
113
+
114
+ pob3.add('cart', cron='0 20 * * 3', needs='shopping') # 8pm on Wednesdays
115
+ pob3.push('shopping', 'yoghurt, the greek one', key='img_2201.heic')
116
+ pob3.push('shopping', 'yoghurt, the greek one', key='img_2201.heic') # the same photo twice
117
+ pob3.push('shopping', 'oat milk')
118
+ pob3.items('shopping').attrgot('text')
119
+ ```
120
+
121
+ ``` python
122
+ time.sleep(1.05)
123
+ got = pob3.tick(at=int(time.time()) + 7*86400).ran[0]
124
+ got.status, got.result, got.used
125
+ ```
126
+
127
+ When the list is empty, the next fire is `skipped`. It is not a callback failure and produces no note.
128
+
129
+ ``` python
130
+ time.sleep(1.05)
131
+ nxt = pob3.tick(at=int(time.time()) + 14*86400).ran[0]
132
+ nxt.status, nxt.why
133
+ ```
134
+
135
+ ## Use a model callback
136
+
137
+ A model is another callable dependency. The documentation build does not run this example because it needs a LiteRT model.
138
+
139
+ ``` python
140
+ import rishi
141
+
142
+ pob4 = Pob()
143
+
144
+ @pob4.on('news')
145
+ def digest(fire):
146
+ chat = rishi.Chat('litert-community/gemma-4-E2B-it-litert-lm')
147
+ topics = ', '.join(i.text for i in fire.list)
148
+ return str(chat(f'Name one thing worth reading about each of: {topics}. One line each.'))
149
+
150
+ pob4.add('news', cron='0 7 * * *', needs='interests')
151
+ pob4.push('interests', 'sanskrit grammar')
152
+ pob4.push('interests', 'sqlite internals')
153
+ pob4.tick()
154
+ ```
155
+
156
+ ## Operate schedules
157
+
158
+ Use `pause`, `resume`, `update`, and `drop` to maintain schedules. `update` changes only the fields you pass. `drop` also unregisters the callback in the current process.
159
+
160
+ ``` python
161
+ pob4.pause('news')
162
+ pob4.update('news', cron='30 7 * * *', retries=5)
163
+ pob4.resume('news')
164
+ pob4.drop('news')
165
+ ```
166
+
167
+ ``` python
168
+ # Queue due fires without running callbacks.
169
+ queued = pob.tick(run=False)
170
+
171
+ # Run queued fires separately, with a bounded batch.
172
+ results = pob.work(worker='scheduler', limit=100)
173
+ ```
174
+
175
+ ## Failures and missed fires
176
+
177
+ A fire retries with exponential backoff when its callback raises. The default attempt budget is three. Set `retries` on [`Pob`](https://vedicreader.github.io/pobblebonk/core.html#pob) or on one schedule. After the final attempt, the fire is dead-lettered and a note records the error.
178
+
179
+ `catchup='once'` keeps the latest fire missed while the machine was off. This is the default. `catchup='all'` keeps every missed fire.
180
+
181
+ Use `tick(run=False)` when scheduling and callback execution belong in separate processes. It queues due fires without running them. `work` claims and runs queued fires.
182
+
183
+ ``` python
184
+ pob = Pob('~/.pobblebonk/pob.db', retries=3)
185
+ pob.add('roundup', cron='0 17 * * *', catchup='once', retries=5)
186
+ ```
187
+
188
+ ## Run the tick
189
+
190
+ The process that calls `tick` must register the callbacks first. A small script is enough.
191
+
192
+ ``` python
193
+ # tick.py
194
+ from pobblebonk.core import Pob
195
+
196
+ pob = Pob('~/.pobblebonk/pob.db')
197
+
198
+ @pob.on('cart')
199
+ def cart(fire):
200
+ return 'added: ' + ', '.join(item.text for item in fire.list)
201
+
202
+ if __name__ == '__main__':
203
+ print(pob.tick())
204
+ ```
205
+
206
+ Run it once a minute. A tick with nothing due is one SQL call.
207
+
208
+ ``` cron
209
+ * * * * * cd ~/myapp && uv run python tick.py
210
+ ```
211
+
212
+ Use a launchd `StartInterval` of 60 on macOS or a systemd timer with `OnCalendar=minutely` on Linux.
213
+
214
+ ## Share an existing database
215
+
216
+ Pass an open honker database to keep pobblebonk data beside another application.
217
+
218
+ ``` python
219
+ import honker
220
+
221
+ db = honker.open('app.db')
222
+ pob = Pob(db=db)
223
+ ```
224
+
225
+ ## Develop
226
+
227
+ ``` sh
228
+ uv sync --group dev
229
+ uv run nbdev-export
230
+ uv run nbdev-test
231
+ uv run nbdev-clean
232
+ ```
233
+
234
+ `nbs/00_core.ipynb` contains the implementation and tests. `nbs/index.ipynb` generates this README.
@@ -0,0 +1,212 @@
1
+ # pobblebonk
2
+
3
+
4
+ <!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->
5
+
6
+ > the clock and the notebook for an agent
7
+
8
+ `pobblebonk` adds callbacks, durable lists, and per-reader notes to [honker](https://github.com/russellromney/honker). Schedules, queued work, list items, retries, and notes share one SQLite file.
9
+
10
+ There is no scheduler daemon. Call [`Pob.tick()`](https://vedicreader.github.io/pobblebonk/core.html#pob.tick) from cron, launchd, or a systemd timer. Each tick asks honker for due fires and runs their callbacks.
11
+
12
+ ## Install
13
+
14
+ ``` sh
15
+ uv add pobblebonk
16
+ ```
17
+
18
+ Python 3.12 or later is required.
19
+
20
+ ## Schedule a callback
21
+
22
+ Register a callback, add its schedule, then call `tick`. The callback return value becomes a note.
23
+
24
+ ``` python
25
+ import time
26
+
27
+ pob = Pob()
28
+ beats = []
29
+
30
+ @pob.on('heartbeat')
31
+ def beat(fire):
32
+ beats.append(fire.fire_at)
33
+ return f'beat {len(beats)} at {fire.fire_at}'
34
+
35
+ pob.add('heartbeat', every='1s')
36
+ for _ in range(3):
37
+ time.sleep(1.05)
38
+ pob.tick()
39
+
40
+ beats
41
+ ```
42
+
43
+ `fire.fire_at` is the scheduled boundary, not the time the callback happened. The one-second gaps show that the cadence held.
44
+
45
+ ``` python
46
+ [b-a for a, b in zip(beats, beats[1:])]
47
+ ```
48
+
49
+ `drain` returns the notes a reader has not seen. Each reader has an independent cursor.
50
+
51
+ ``` python
52
+ pob.drain('leela').attrgot('body')
53
+ ```
54
+
55
+ ``` python
56
+ pob.drain('leela'), len(pob.drain('phone'))
57
+ ```
58
+
59
+ ## Run ordinary Python
60
+
61
+ A callback is a Python function. It can call a library, write a file, or run a command.
62
+
63
+ ``` python
64
+ import subprocess
65
+
66
+ pob2 = Pob()
67
+
68
+ @pob2.on('git roundup')
69
+ def roundup(fire):
70
+ out = subprocess.run(['git', 'log', '--oneline', '--since=30 days ago'],
71
+ capture_output=True, text=True).stdout.strip().splitlines()
72
+ return f'{len(out)} commits in the last 30 days'
73
+
74
+ pob2.add('git roundup', cron='0 17 * * *') # 5pm daily
75
+ time.sleep(1.05)
76
+ pob2.tick(at=int(time.time()) + 86400).ran[0].result
77
+ ```
78
+
79
+ ## Give a callback a durable list
80
+
81
+ `push` adds an item to a named list. A schedule with `needs` runs only when that list is not empty. Its callback receives the open items as `fire.list`.
82
+
83
+ The callback marks its items used when it returns. If it raises, the items remain open for the retry. A `key` makes repeated open items idempotent.
84
+
85
+ ``` python
86
+ pob3 = Pob()
87
+
88
+ @pob3.on('cart')
89
+ def cart(fire):
90
+ return 'added: ' + ', '.join(i.text for i in fire.list)
91
+
92
+ pob3.add('cart', cron='0 20 * * 3', needs='shopping') # 8pm on Wednesdays
93
+ pob3.push('shopping', 'yoghurt, the greek one', key='img_2201.heic')
94
+ pob3.push('shopping', 'yoghurt, the greek one', key='img_2201.heic') # the same photo twice
95
+ pob3.push('shopping', 'oat milk')
96
+ pob3.items('shopping').attrgot('text')
97
+ ```
98
+
99
+ ``` python
100
+ time.sleep(1.05)
101
+ got = pob3.tick(at=int(time.time()) + 7*86400).ran[0]
102
+ got.status, got.result, got.used
103
+ ```
104
+
105
+ When the list is empty, the next fire is `skipped`. It is not a callback failure and produces no note.
106
+
107
+ ``` python
108
+ time.sleep(1.05)
109
+ nxt = pob3.tick(at=int(time.time()) + 14*86400).ran[0]
110
+ nxt.status, nxt.why
111
+ ```
112
+
113
+ ## Use a model callback
114
+
115
+ A model is another callable dependency. The documentation build does not run this example because it needs a LiteRT model.
116
+
117
+ ``` python
118
+ import rishi
119
+
120
+ pob4 = Pob()
121
+
122
+ @pob4.on('news')
123
+ def digest(fire):
124
+ chat = rishi.Chat('litert-community/gemma-4-E2B-it-litert-lm')
125
+ topics = ', '.join(i.text for i in fire.list)
126
+ return str(chat(f'Name one thing worth reading about each of: {topics}. One line each.'))
127
+
128
+ pob4.add('news', cron='0 7 * * *', needs='interests')
129
+ pob4.push('interests', 'sanskrit grammar')
130
+ pob4.push('interests', 'sqlite internals')
131
+ pob4.tick()
132
+ ```
133
+
134
+ ## Operate schedules
135
+
136
+ Use `pause`, `resume`, `update`, and `drop` to maintain schedules. `update` changes only the fields you pass. `drop` also unregisters the callback in the current process.
137
+
138
+ ``` python
139
+ pob4.pause('news')
140
+ pob4.update('news', cron='30 7 * * *', retries=5)
141
+ pob4.resume('news')
142
+ pob4.drop('news')
143
+ ```
144
+
145
+ ``` python
146
+ # Queue due fires without running callbacks.
147
+ queued = pob.tick(run=False)
148
+
149
+ # Run queued fires separately, with a bounded batch.
150
+ results = pob.work(worker='scheduler', limit=100)
151
+ ```
152
+
153
+ ## Failures and missed fires
154
+
155
+ A fire retries with exponential backoff when its callback raises. The default attempt budget is three. Set `retries` on [`Pob`](https://vedicreader.github.io/pobblebonk/core.html#pob) or on one schedule. After the final attempt, the fire is dead-lettered and a note records the error.
156
+
157
+ `catchup='once'` keeps the latest fire missed while the machine was off. This is the default. `catchup='all'` keeps every missed fire.
158
+
159
+ Use `tick(run=False)` when scheduling and callback execution belong in separate processes. It queues due fires without running them. `work` claims and runs queued fires.
160
+
161
+ ``` python
162
+ pob = Pob('~/.pobblebonk/pob.db', retries=3)
163
+ pob.add('roundup', cron='0 17 * * *', catchup='once', retries=5)
164
+ ```
165
+
166
+ ## Run the tick
167
+
168
+ The process that calls `tick` must register the callbacks first. A small script is enough.
169
+
170
+ ``` python
171
+ # tick.py
172
+ from pobblebonk.core import Pob
173
+
174
+ pob = Pob('~/.pobblebonk/pob.db')
175
+
176
+ @pob.on('cart')
177
+ def cart(fire):
178
+ return 'added: ' + ', '.join(item.text for item in fire.list)
179
+
180
+ if __name__ == '__main__':
181
+ print(pob.tick())
182
+ ```
183
+
184
+ Run it once a minute. A tick with nothing due is one SQL call.
185
+
186
+ ``` cron
187
+ * * * * * cd ~/myapp && uv run python tick.py
188
+ ```
189
+
190
+ Use a launchd `StartInterval` of 60 on macOS or a systemd timer with `OnCalendar=minutely` on Linux.
191
+
192
+ ## Share an existing database
193
+
194
+ Pass an open honker database to keep pobblebonk data beside another application.
195
+
196
+ ``` python
197
+ import honker
198
+
199
+ db = honker.open('app.db')
200
+ pob = Pob(db=db)
201
+ ```
202
+
203
+ ## Develop
204
+
205
+ ``` sh
206
+ uv sync --group dev
207
+ uv run nbdev-export
208
+ uv run nbdev-test
209
+ uv run nbdev-clean
210
+ ```
211
+
212
+ `nbs/00_core.ipynb` contains the implementation and tests. `nbs/index.ipynb` generates this README.
@@ -0,0 +1,2 @@
1
+ __version__ = "0.0.1"
2
+ from .core import *
@@ -0,0 +1,37 @@
1
+ # Autogenerated by nbdev
2
+
3
+ d = { 'settings': { 'branch': 'main',
4
+ 'doc_baseurl': '/pobblebonk',
5
+ 'doc_host': 'https://vedicreader.github.io',
6
+ 'git_url': 'https://github.com/vedicreader/pobblebonk',
7
+ 'lib_path': 'pobblebonk'},
8
+ 'syms': { 'pobblebonk.core': { 'pobblebonk.core.Pob': ('core.html#pob', 'pobblebonk/core.py'),
9
+ 'pobblebonk.core.Pob.__init__': ('core.html#pob.__init__', 'pobblebonk/core.py'),
10
+ 'pobblebonk.core.Pob.__repr__': ('core.html#pob.__repr__', 'pobblebonk/core.py'),
11
+ 'pobblebonk.core.Pob._done': ('core.html#pob._done', 'pobblebonk/core.py'),
12
+ 'pobblebonk.core.Pob._failed': ('core.html#pob._failed', 'pobblebonk/core.py'),
13
+ 'pobblebonk.core.Pob._fire': ('core.html#pob._fire', 'pobblebonk/core.py'),
14
+ 'pobblebonk.core.Pob._row': ('core.html#pob._row', 'pobblebonk/core.py'),
15
+ 'pobblebonk.core.Pob._run': ('core.html#pob._run', 'pobblebonk/core.py'),
16
+ 'pobblebonk.core.Pob.add': ('core.html#pob.add', 'pobblebonk/core.py'),
17
+ 'pobblebonk.core.Pob.all': ('core.html#pob.all', 'pobblebonk/core.py'),
18
+ 'pobblebonk.core.Pob.drain': ('core.html#pob.drain', 'pobblebonk/core.py'),
19
+ 'pobblebonk.core.Pob.drop': ('core.html#pob.drop', 'pobblebonk/core.py'),
20
+ 'pobblebonk.core.Pob.due': ('core.html#pob.due', 'pobblebonk/core.py'),
21
+ 'pobblebonk.core.Pob.get': ('core.html#pob.get', 'pobblebonk/core.py'),
22
+ 'pobblebonk.core.Pob.items': ('core.html#pob.items', 'pobblebonk/core.py'),
23
+ 'pobblebonk.core.Pob.note': ('core.html#pob.note', 'pobblebonk/core.py'),
24
+ 'pobblebonk.core.Pob.notes': ('core.html#pob.notes', 'pobblebonk/core.py'),
25
+ 'pobblebonk.core.Pob.on': ('core.html#pob.on', 'pobblebonk/core.py'),
26
+ 'pobblebonk.core.Pob.pause': ('core.html#pob.pause', 'pobblebonk/core.py'),
27
+ 'pobblebonk.core.Pob.push': ('core.html#pob.push', 'pobblebonk/core.py'),
28
+ 'pobblebonk.core.Pob.resume': ('core.html#pob.resume', 'pobblebonk/core.py'),
29
+ 'pobblebonk.core.Pob.tick': ('core.html#pob.tick', 'pobblebonk/core.py'),
30
+ 'pobblebonk.core.Pob.update': ('core.html#pob.update', 'pobblebonk/core.py'),
31
+ 'pobblebonk.core.Pob.used': ('core.html#pob.used', 'pobblebonk/core.py'),
32
+ 'pobblebonk.core.Pob.work': ('core.html#pob.work', 'pobblebonk/core.py'),
33
+ 'pobblebonk.core._note': ('core.html#_note', 'pobblebonk/core.py'),
34
+ 'pobblebonk.core.backoff': ('core.html#backoff', 'pobblebonk/core.py'),
35
+ 'pobblebonk.core.clip': ('core.html#clip', 'pobblebonk/core.py'),
36
+ 'pobblebonk.core.secs': ('core.html#secs', 'pobblebonk/core.py'),
37
+ 'pobblebonk.core.superseded': ('core.html#superseded', 'pobblebonk/core.py')}}}
@@ -0,0 +1,306 @@
1
+ """schedules, the lists they read, and the notes they leave
2
+
3
+ Docs: https://vedicreader.github.io/pobblebonk/core.html.md"""
4
+
5
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb.
6
+
7
+ # %% auto #0
8
+ __all__ = ['FIRES', 'NOTES', 'RETRIES', 'BASE', 'MAX_TEXT', 'CATCHUP', 'secs', 'backoff', 'clip', 'Pob', 'superseded']
9
+
10
+ # %% ../nbs/00_core.ipynb #8b4162b7
11
+ import json, re, time
12
+ from pathlib import Path
13
+ from tempfile import mkdtemp
14
+
15
+ import honker
16
+ from fastcore.all import AttrDict, L, first, ifnone, patch, dict2obj
17
+ from honker import crontab, every_s
18
+
19
+ # %% ../nbs/00_core.ipynb #2dc2cc67
20
+ _DUR = re.compile(r'([\d.]+)\s*([smhdwMy])', re.I)
21
+ _MULT = dict(s=1, m=60, h=3600, d=86400, w=604800, M=2629800, y=31557600)
22
+
23
+ # %% ../nbs/00_core.ipynb #a807caa9
24
+ def secs(every) -> int:
25
+ "Whole seconds from `'30m'`, `'6h'`, `'1w'`, `'1h30m'`, or a number."
26
+ try: return int(float(every))
27
+ except (TypeError, ValueError): pass
28
+ if not (ms := _DUR.findall(str(every))): raise ValueError(f'not a duration: {every!r}')
29
+ return int(sum(float(n) * _MULT[u.lower()] for n, u in ms))
30
+
31
+ # %% ../nbs/00_core.ipynb #57b9ddaf
32
+ FIRES = 'pob.fires' # the honker queue every schedule fires into
33
+ NOTES = 'pob.notes' # the honker stream a person reads
34
+ RETRIES = 5 # attempts a fire gets before it is dead-lettered
35
+ BASE = 30 # seconds before the first retry; doubles per attempt
36
+
37
+ # %% ../nbs/00_core.ipynb #6ecca23b
38
+ def backoff(attempt:int, # failures so far; 1 on the first
39
+ base:float=BASE, # the first delay, in seconds
40
+ cap:float=3600, # longest a retry ever waits
41
+ ) -> float:
42
+ 'Doubling delay: 30s, 60s, 120s, 240s, capped. A bot wall clears on its own or it does not.'
43
+ return min(base * 2 ** max(0, attempt - 1), cap)
44
+
45
+ # %% ../nbs/00_core.ipynb #755f5560
46
+ MAX_TEXT = 20_000 # chars kept of a note body; an answer is a note, not a document
47
+ def clip(s, mx:int=MAX_TEXT) -> str:
48
+ 'Text short enough to store, saying what it dropped rather than losing it quietly.'
49
+ s = '' if s is None else str(s)
50
+ return s if len(s) <= mx else s[:mx] + f'\n... [{len(s)-mx} more chars]'
51
+
52
+ # %% ../nbs/00_core.ipynb #25758930
53
+ class Pob:
54
+ "A honker database, the one table honker has not got, and the callbacks a fire runs."
55
+ def __init__(self,
56
+ path=None, # the file; None makes a temporary one
57
+ db=None, # or a honker database something else already opened
58
+ retries:int=RETRIES,# attempts a failing fire gets before it is dead-lettered
59
+ base:float=BASE): # seconds before the first retry; doubles per attempt
60
+ # honker watches `PRAGMA data_version` on a file, so `:memory:` is not an option
61
+ if db is None and path is None: path = Path(mkdtemp())/'pob.db'
62
+ self.db = db if db is not None else honker.open(str(path))
63
+ self.retries, self.base = max(1, int(retries)), float(base)
64
+ self.q, self.stream = self.db.queue(FIRES), self.db.stream(NOTES)
65
+ self.sched, self.runners = honker.Scheduler(self.db), {}
66
+ with self.db.transaction() as tx:
67
+ tx.execute('CREATE TABLE IF NOT EXISTS pob_items (id INTEGER PRIMARY KEY, topic TEXT, '
68
+ 'text TEXT, key TEXT, at REAL, used_at REAL, used_by TEXT)')
69
+ tx.execute('CREATE INDEX IF NOT EXISTS pob_items_open ON pob_items(topic, at) '
70
+ 'WHERE used_at IS NULL')
71
+ tx.execute('CREATE UNIQUE INDEX IF NOT EXISTS pob_items_key ON pob_items(topic, key) '
72
+ 'WHERE key IS NOT NULL AND used_at IS NULL')
73
+
74
+ # %% ../nbs/00_core.ipynb #4a1865e6
75
+ @patch
76
+ def note(self:Pob, title:str, body:str='', **meta):
77
+ 'Leave one note.'
78
+ self.stream.publish(dict(title=str(title), body=clip(body), **meta))
79
+
80
+ def _note(r): return AttrDict(json.loads(r['payload']), offset=r['offset'])
81
+ @patch
82
+ def notes(self:Pob, limit:int=50) -> L:
83
+ 'Every note, newest first, whatever any reader has seen.'
84
+ return L(self.db.query('SELECT offset, payload FROM _honker_stream WHERE topic=? '
85
+ 'ORDER BY offset DESC LIMIT ?', [NOTES, limit])).map(_note)
86
+
87
+ @patch
88
+ def drain(self:Pob, reader:str, limit:int=50) -> L:
89
+ 'Every note this reader has not had. What a turn calls before it answers you.'
90
+ got = L(self.db.query('SELECT offset, payload FROM _honker_stream WHERE topic=? AND offset>? '
91
+ 'ORDER BY offset LIMIT ?',
92
+ [NOTES, self.stream.get_offset(str(reader)), limit]))
93
+ if got: self.stream.save_offset(str(reader), got[-1]['offset'])
94
+ return got.map(_note)
95
+
96
+ # %% ../nbs/00_core.ipynb #0396b45d138a
97
+ @patch
98
+ def due(self:Pob) -> int:
99
+ 'Fires queued and not yet run.'
100
+ return self.db.query("SELECT count(*) n FROM _honker_live WHERE queue=? AND state='pending' AND run_at <= unixepoch()",
101
+ [FIRES])[0]['n']
102
+ @patch
103
+ def all(self:Pob) -> L:
104
+ 'Every schedule, with its payload parsed.'
105
+ return L(self.sched.list()).map(lambda r: AttrDict(r, payload=json.loads(r['payload'] or 'null')))
106
+
107
+ @patch
108
+ def __repr__(self:Pob):
109
+ return f'Pob({len(self.all())} schedules, {self.due()} due, {len(self.notes())} notes)'
110
+
111
+
112
+ # %% ../nbs/00_core.ipynb #21d054f1
113
+ CATCHUP = ('once', 'all') # what to do with the fires missed while the machine was off
114
+ @patch
115
+ def add(self:Pob,
116
+ name:str, # what you call it; unique, and what a note is titled with
117
+ cron:str=None, # a five-field cron expression
118
+ every=None, # or a duration: `'30m'`, `'1w'`, or seconds
119
+ needs:str=None, # a list topic this cannot run without
120
+ catchup:str='once', # `once` runs the latest missed fire, `all` runs every one
121
+ retries:int=None, # attempts a failing fire gets; None -> the Pob's own
122
+ **payload # anything the callback wants
123
+ ) -> AttrDict:
124
+ 'Write down a standing instruction.'
125
+ if bool(cron) == bool(every): raise ValueError('a schedule needs one of `cron` or `every`')
126
+ if catchup not in CATCHUP: raise ValueError(f'catchup must be one of {CATCHUP}: {catchup!r}')
127
+ self.sched.add(name=str(name), queue=FIRES, payload=dict(
128
+ schedule=str(name), needs=needs, catchup=catchup, **payload),
129
+ schedule=crontab(cron) if cron else every_s(secs(every)),
130
+ max_attempts=max(1, int(ifnone(retries, self.retries))))
131
+ return self.get(name)
132
+
133
+ @patch
134
+ def on(self:Pob, name:str):
135
+ 'Decorator naming the callback a fire of `name` runs.'
136
+ def _f(fn):
137
+ self.runners[str(name)] = fn
138
+ return fn
139
+ return _f
140
+
141
+ @patch
142
+ def get(self:Pob, name:str) -> AttrDict: return first(r for r in self.all() if r['name'] == str(name))
143
+
144
+ @patch
145
+ def pause(self:Pob, name:str) -> bool: return self.sched.pause(str(name))
146
+
147
+ @patch
148
+ def resume(self:Pob, name:str) -> bool: return self.sched.resume(str(name))
149
+
150
+ @patch
151
+ def drop(self:Pob, name:str) -> bool:
152
+ self.runners.pop(str(name), None)
153
+ return self.sched.remove(str(name))
154
+
155
+ @patch
156
+ def update(self:Pob,
157
+ name:str, # schedule to change
158
+ cron:str=None, # new cron expression
159
+ every=None, # or a new duration
160
+ priority:int=None, # new queue priority
161
+ retries:int=None, # new attempt budget
162
+ **payload # payload fields to add or replace
163
+ ) -> AttrDict:
164
+ 'Change an existing schedule without replacing fields that are not given.'
165
+ if cron is not None and every is not None: raise ValueError('a schedule update accepts only one of `cron` or `every`')
166
+ if 'catchup' in payload and payload['catchup'] not in CATCHUP:
167
+ raise ValueError(f'catchup must be one of {CATCHUP}: {payload["catchup"]!r}')
168
+ if (was := self.get(name)) is None: return None
169
+ changes = {}
170
+ if cron is not None: changes['schedule'] = crontab(cron)
171
+ elif every is not None: changes['schedule'] = every_s(secs(every))
172
+ if priority is not None: changes['priority'] = int(priority)
173
+ if retries is not None: changes['max_attempts'] = max(1, int(retries))
174
+ if payload: changes['payload'] = {**(was.payload or {}), **payload, 'schedule': str(name)}
175
+ self.sched.update(str(name), **changes)
176
+ return self.get(name)
177
+
178
+ # %% ../nbs/00_core.ipynb #1014ad54
179
+ @patch
180
+ def push(self:Pob,
181
+ topic:str, # which list
182
+ text:str, # the line the callback reads
183
+ key:str=None, # send the same thing twice and it stays one item
184
+ at:float=None
185
+ ) -> AttrDict:
186
+ 'Add one item, or give back the open one that `key` already names.'
187
+ at = ifnone(at, time.time())
188
+ with self.db.transaction() as tx:
189
+ if key is not None and (was := tx.query(
190
+ 'SELECT * FROM pob_items WHERE topic=? AND key=? AND used_at IS NULL',
191
+ [str(topic), key])): return AttrDict(was[0])
192
+ got = tx.query('INSERT INTO pob_items (topic, text, key, at) VALUES (?,?,?,?) RETURNING *',
193
+ [str(topic), clip(text), key, at])
194
+ return AttrDict(got[0])
195
+
196
+ @patch
197
+ def items(self:Pob, topic:str, limit:int=200) -> L:
198
+ 'The open items on `topic`, oldest first.'
199
+ return L(self.db.query('SELECT * FROM pob_items WHERE topic=? AND used_at IS NULL '
200
+ 'ORDER BY at LIMIT ?', [str(topic), limit])).map(AttrDict)
201
+
202
+ # %% ../nbs/00_core.ipynb #f156da8b
203
+ @patch
204
+ def used(self:Pob, ids, by:str='', at:float=None) -> int:
205
+ 'Mark items done, once the work that consumed them succeeded.'
206
+ if not (ids := L(ids)): return 0
207
+ qs = ','.join('?'*len(ids))
208
+ with self.db.transaction() as tx:
209
+ return len(tx.query(f'UPDATE pob_items SET used_at=?, used_by=? WHERE id IN ({qs}) '
210
+ 'AND used_at IS NULL RETURNING id',
211
+ [ifnone(at, time.time()), str(by), *ids]))
212
+
213
+ # %% ../nbs/00_core.ipynb #708046dc
214
+ def superseded(fires, keep_latest) -> set:
215
+ 'Older job IDs for schedules that keep only their latest missed fire.'
216
+ fires, keep_latest = L(fires), set(keep_latest)
217
+ return {
218
+ f['job_id']
219
+ for name in {f['name'] for f in fires} & keep_latest
220
+ for f in fires.filter(lambda f: f['name'] == name)[:-1]
221
+ }
222
+
223
+ # %% ../nbs/00_core.ipynb #b5e1bce7
224
+ @patch
225
+ def tick(
226
+ self:Pob,
227
+ at:float=None, # when this tick is happening; now when unset
228
+ worker:str='tick', # the lease holder recorded on each job
229
+ run:bool=True, # False queues the fires and runs none of them
230
+ ) -> AttrDict:
231
+ 'Create due fires, apply catch-up policy, then run the remaining jobs.'
232
+ at = int(ifnone(at, time.time()))
233
+ with self.db.transaction() as tx: fires = L(json.loads(tx.query('SELECT honker_scheduler_tick(?) AS f',[at])[0]['f']))
234
+ keep_latest = {schedule.name for schedule in self.all() if schedule.payload.get('catchup', 'once') == 'once'}
235
+ dropped = {job_id for job_id in superseded(fires, keep_latest) if self.q.cancel(job_id)}
236
+ kept = fires.filter(lambda fire: fire['job_id'] not in dropped)
237
+ fire_times = {fire['job_id']: fire['fire_at'] for fire in kept}
238
+ return dict2obj(dict(fired=kept,cancelled=len(dropped),ran=self.work(worker, at, when=fire_times) if run else L()))
239
+
240
+ # %% ../nbs/00_core.ipynb #65b5766a
241
+ @patch
242
+ def _row(self:Pob, job) -> AttrDict:
243
+ 'fetch specific honker job'
244
+ return AttrDict(first(self.db.query('SELECT * FROM _honker_live WHERE id=?', [job.id])) or {})
245
+
246
+ # %% ../nbs/00_core.ipynb #9e7d9518
247
+ @patch
248
+ def _failed(self:Pob, job, row, name:str, exc:Exception) -> AttrDict:
249
+ 'Retry a failed callback while attempts remain, else dead-letter it.'
250
+ err = f'{type(exc).__name__}: {exc}'
251
+ left = max(0, int(row.get('max_attempts') or job.max_attempts or 1) - job.attempts)
252
+ if left:
253
+ delay = backoff(job.attempts, self.base)
254
+ if job.retry(int(delay),err): return AttrDict(name=name,status='retry',error=err,attempt=job.attempts,attempts_left=left,after=delay)
255
+ job.fail(err)
256
+ self.note(name, f'gave up after {job.attempts} tries. {err}', ref=job.id, dead=True)
257
+ return AttrDict(name=name, status='error', error=err, attempt=job.attempts)
258
+
259
+ # %% ../nbs/00_core.ipynb #83ba6c2c
260
+ @patch
261
+ def _done(self:Pob, job, name:str, got, res) -> AttrDict:
262
+ 'Store a successful callback result and finish its fire.'
263
+ self.q.save_result(job.id, res)
264
+ if got: self.used(got.attrgot('id'), by=name)
265
+ body = res if isinstance(res, str) else json.dumps(res, default=str)
266
+ self.note(name, body, ref=job.id, used=len(got))
267
+ job.ack()
268
+ return AttrDict(name=name, status='ok', result=res, used=len(got))
269
+
270
+ # %% ../nbs/00_core.ipynb #b034d25c
271
+ @patch
272
+ def _fire(self:Pob, job, at:int, fire_at:int=None):
273
+ 'Build the value passed to a fire callback.'
274
+ p, row = AttrDict(job.payload or {}), self._row(job)
275
+ got = self.items(p.needs) if p.get('needs') else L()
276
+ fire_at = ifnone(fire_at, row.get('run_at') or at)
277
+ return AttrDict(p, at=at, fire_at=fire_at, list=got, job=job.id), row
278
+
279
+ # %% ../nbs/00_core.ipynb #df9981a3
280
+ @patch
281
+ def _run(self:Pob, job, at:int, fire_at:int=None) -> AttrDict:
282
+ 'Run one claimed fire.'
283
+ p = AttrDict(job.payload or {})
284
+ name = p.get('schedule') or ''
285
+ if (fn := self.runners.get(name)) is None:
286
+ job.fail(err := f'no callback registered for {name!r}')
287
+ self.note(name, err, ref=job.id, dead=True)
288
+ return AttrDict(name=name, status='error', error=err)
289
+ fire, row = self._fire(job, at, fire_at)
290
+ got = fire.list
291
+ if fire.get('needs') and not got:
292
+ job.ack()
293
+ return AttrDict(name=name, status='skipped', why=f'the {fire.needs} list is empty')
294
+ try: res = fn(fire)
295
+ except Exception as e: return self._failed(job, row, name, e)
296
+ return self._done(job, name, got, res)
297
+
298
+ # %% ../nbs/00_core.ipynb #f8d9c278
299
+ @patch
300
+ def work(self:Pob, worker:str='tick', at:float=None, limit:int=100, when:dict=None) -> L:
301
+ 'Claim and run every fire that is due.'
302
+ at, when, out = int(ifnone(at, time.time())), when or {}, L()
303
+ while len(out) < limit:
304
+ if (job := self.q.claim_one(worker)) is None: break
305
+ out.append(self._run(job, at, when.get(job.id)))
306
+ return out
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pobblebonk"
7
+ dynamic = ["version"]
8
+ description = "the clock and the notebook for an agent: schedules, lists and notes on honker, in one file"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = {text = "Apache-2.0"}
12
+ authors = [{name = "Karthik", email = "karthik.rajgopal@hotmail.com"}]
13
+ keywords = ['nbdev', 'sqlite', 'honker', 'cron', 'scheduler', 'reminders', 'agents', 'acp']
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Software Development :: Libraries",
23
+ ]
24
+ dependencies = [
25
+ "honker>=0.5.0",
26
+ "fastcore>=2.2.14",
27
+ ]
28
+
29
+ [project.entry-points.nbdev]
30
+ pobblebonk = "pobblebonk._modidx:d"
31
+
32
+ [project.urls]
33
+ Repository = "https://github.com/vedicreader/pobblebonk"
34
+ Documentation = "https://vedicreader.github.io/pobblebonk/"
35
+
36
+ [tool.nbdev]
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["pobblebonk"]
40
+
41
+ [tool.hatch.build.targets.sdist]
42
+ include = ["/pobblebonk", "/README.md", "/pyproject.toml"]
43
+
44
+ [tool.hatch.version]
45
+ path = "pobblebonk/__init__.py"
46
+
47
+ [dependency-groups]
48
+ dev = [
49
+ "nbdev>=3.3.12",
50
+ "notebook>=7.6.1",
51
+ "slopometer>=0.0.1",
52
+ ]