calendardb 0.1.0__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.
- calendardb-0.1.0/.github/workflows/test.yml +20 -0
- calendardb-0.1.0/.gitignore +12 -0
- calendardb-0.1.0/LICENSE +22 -0
- calendardb-0.1.0/PKG-INFO +86 -0
- calendardb-0.1.0/README.md +63 -0
- calendardb-0.1.0/docs/demo.md +62 -0
- calendardb-0.1.0/examples/seed_database.py +142 -0
- calendardb-0.1.0/launch-thread.md +9 -0
- calendardb-0.1.0/pyproject.toml +42 -0
- calendardb-0.1.0/src/calendardb/__init__.py +13 -0
- calendardb-0.1.0/src/calendardb/auth.py +113 -0
- calendardb-0.1.0/src/calendardb/backend.py +35 -0
- calendardb-0.1.0/src/calendardb/backend_calendar.py +254 -0
- calendardb-0.1.0/src/calendardb/buffer.py +44 -0
- calendardb-0.1.0/src/calendardb/cli.py +33 -0
- calendardb-0.1.0/src/calendardb/client.py +65 -0
- calendardb-0.1.0/src/calendardb/codec.py +24 -0
- calendardb-0.1.0/src/calendardb/logging_handler.py +38 -0
- calendardb-0.1.0/src/calendardb/otel.py +47 -0
- calendardb-0.1.0/src/calendardb/timestamps.py +24 -0
- calendardb-0.1.0/tests/test_auth.py +126 -0
- calendardb-0.1.0/tests/test_backend_calendar.py +270 -0
- calendardb-0.1.0/tests/test_buffer.py +44 -0
- calendardb-0.1.0/tests/test_cli.py +26 -0
- calendardb-0.1.0/tests/test_client.py +95 -0
- calendardb-0.1.0/tests/test_codec.py +18 -0
- calendardb-0.1.0/tests/test_logging_handler.py +34 -0
- calendardb-0.1.0/tests/test_otel.py +69 -0
- calendardb-0.1.0/tests/test_seed_database.py +75 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
pull_request:
|
|
5
|
+
jobs:
|
|
6
|
+
test:
|
|
7
|
+
runs-on: ubuntu-24.04
|
|
8
|
+
strategy:
|
|
9
|
+
matrix:
|
|
10
|
+
python: ['3.10', '3.14']
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v7
|
|
13
|
+
- uses: actions/setup-python@v7
|
|
14
|
+
with:
|
|
15
|
+
python-version: ${{ matrix.python }}
|
|
16
|
+
- run: python -m pip install -e '.[dev,otel]'
|
|
17
|
+
- run: ruff check .
|
|
18
|
+
- run: python -m pytest -q
|
|
19
|
+
- run: python examples/seed_database.py --dry-run
|
|
20
|
+
- run: python -m build
|
calendardb-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CalendarDB contributors
|
|
4
|
+
Copyright (c) 2026 GranolaDB contributors
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: calendardb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Store your logs and agent traces in Google Calendar. Your database has a day view.
|
|
5
|
+
Project-URL: Homepage, https://github.com/that-guy-wade/calendardb
|
|
6
|
+
Project-URL: Repository, https://github.com/that-guy-wade/calendardb
|
|
7
|
+
Author: CalendarDB
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: calendar,database,logging,observability,satire
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: google-auth-oauthlib>=1
|
|
13
|
+
Requires-Dist: google-auth>=2
|
|
14
|
+
Requires-Dist: keyring>=24
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: build>=1; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
18
|
+
Requires-Dist: radon>=6; extra == 'dev'
|
|
19
|
+
Requires-Dist: ruff>=0.9; extra == 'dev'
|
|
20
|
+
Provides-Extra: otel
|
|
21
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == 'otel'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# CalendarDB
|
|
25
|
+
|
|
26
|
+
CalendarDB is a tiny time-series store with a day view. It writes each record as a private, transparent one-second event in a dedicated Google Calendar, then reads the event description back as JSON. Your latency graph is now something you can scroll past on the way to lunch.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install 'calendardb[otel]'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Run the synthetic demo from a source checkout
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
python -m venv .venv
|
|
38
|
+
. .venv/bin/activate
|
|
39
|
+
pip install -e '.[otel]'
|
|
40
|
+
python examples/seed_database.py --dry-run
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The dry run uses the same `Client` and seed path as live mode, backed by an in-memory fake. It writes 12 synthetic API measurements for the current UTC day and verifies the complete readback. No Google account or network access is used.
|
|
44
|
+
|
|
45
|
+
For live storage, create an OAuth desktop client in Google Cloud, enable the Google Calendar API, download the desktop OAuth JSON outside this repository, and authorize CalendarDB:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
calendardb login --client-secrets /path/outside/repository/client_secret.json
|
|
49
|
+
python examples/seed_database.py
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The OAuth flow requests `calendar.app.created` and `calendar.calendarlist.readonly`. CalendarDB creates a separate `CalendarDB Demo` calendar and does not use your primary calendar. Keep the OAuth JSON out of source control; the refresh token is saved in your operating system keychain. `calendardb logout` removes that saved authorization.
|
|
53
|
+
|
|
54
|
+
## Use it
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from datetime import datetime, timedelta, timezone
|
|
58
|
+
|
|
59
|
+
from calendardb import Client
|
|
60
|
+
|
|
61
|
+
db = Client(calendar="CalendarDB Demo")
|
|
62
|
+
now = datetime.now(timezone.utc)
|
|
63
|
+
db.put({"service": "api", "series": "latency_ms", "value": 95, "_ts": now})
|
|
64
|
+
rows = db.query(after=now, before=now + timedelta(seconds=1), series="latency_ms")
|
|
65
|
+
print(rows[0]["value"])
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`put` adds an aware UTC `_ts` timestamp and UUID `_id` when they are missing. `query` returns records in timestamp order; `after` includes its timestamp and `before` excludes its timestamp. `contains` searches serialized record text with a case-sensitive local match. `calendar_query` passes a text search to Google Calendar, and `series` matches the `series` field exactly. Buffered writes flush at 50 records or after five seconds by default, with one Calendar event per record. IDs are immutable: retrying the same ID and payload is safe; writing different data under an existing ID fails. `get(id)` reads a record and `delete(id)` removes only a verified CalendarDB event.
|
|
69
|
+
|
|
70
|
+
For standard Python logging, use `CalendarHandler` from `calendardb.logging_handler`. For OpenTelemetry, install the `otel` extra and configure `CalendarSpanExporter` from `calendardb.otel` as a span exporter. These are ordinary records under the same timestamp and event model; the Calendar UI does not become an observability dashboard by wishing very hard.
|
|
71
|
+
|
|
72
|
+
## What the calendar means
|
|
73
|
+
|
|
74
|
+
The event start time is the record timestamp. Each event description contains the record, and each summary is a short readable preview. The `series` field is just a record dimension. Calendar labels are not used, and CalendarDB does not create a database index: the event start time is the timestamp index, the event rows are the records, and the dedicated calendar is the collection.
|
|
75
|
+
|
|
76
|
+
Google's published account storage allowance lists Gmail, Drive, and Photos; Calendar is not listed there. The careful reading is that event text is outside that listed allowance, not that CalendarDB has unlimited storage. Attachments stored in Drive still count toward Drive storage. Calendar API quotas, general usage limits, and operational limits still apply. Google's current published API quotas for projects created on or after May 1, 2026 are 10,000 requests per minute per project and 600 per minute per user per project; the page describes a 1,000,000-request daily threshold before charges and says further billing details are planned for later in 2026. Google also applies undisclosed single-calendar write limits. Its [paid Workspace guidance](https://knowledge.workspace.google.com/admin/calendar/avoid-calendar-use-limits) warns about creating over 100,000 events or more than 60 calendars in a short period; stricter account limits are not fully public. These are activity limits, not a published total storage capacity.
|
|
77
|
+
|
|
78
|
+
CalendarDB is a satire/demo project. Google Calendar is a calendar, and event-based storage inherits calendar-shaped limits and ergonomics. A day view is not a query planner.
|
|
79
|
+
|
|
80
|
+
## Project
|
|
81
|
+
|
|
82
|
+
Adapted from [GranolaDB](https://github.com/that-guy-wade/granoladb), with Google Calendar events as the storage backend.
|
|
83
|
+
|
|
84
|
+
See [the demo walkthrough](docs/demo.md) and [launch copy](launch-thread.md). The demo values are synthetic and marked `calendardb-demo-v1`; they are not captured from a real service.
|
|
85
|
+
|
|
86
|
+
References: [Calendar API quotas and usage limits](https://developers.google.com/workspace/calendar/api/guides/quota), [Google storage policy](https://support.google.com/googleone/answer/6374270), [Gemini Apps and Google Calendar](https://support.google.com/gemini/answer/15305236).
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# CalendarDB
|
|
2
|
+
|
|
3
|
+
CalendarDB is a tiny time-series store with a day view. It writes each record as a private, transparent one-second event in a dedicated Google Calendar, then reads the event description back as JSON. Your latency graph is now something you can scroll past on the way to lunch.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install 'calendardb[otel]'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Run the synthetic demo from a source checkout
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
python -m venv .venv
|
|
15
|
+
. .venv/bin/activate
|
|
16
|
+
pip install -e '.[otel]'
|
|
17
|
+
python examples/seed_database.py --dry-run
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The dry run uses the same `Client` and seed path as live mode, backed by an in-memory fake. It writes 12 synthetic API measurements for the current UTC day and verifies the complete readback. No Google account or network access is used.
|
|
21
|
+
|
|
22
|
+
For live storage, create an OAuth desktop client in Google Cloud, enable the Google Calendar API, download the desktop OAuth JSON outside this repository, and authorize CalendarDB:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
calendardb login --client-secrets /path/outside/repository/client_secret.json
|
|
26
|
+
python examples/seed_database.py
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The OAuth flow requests `calendar.app.created` and `calendar.calendarlist.readonly`. CalendarDB creates a separate `CalendarDB Demo` calendar and does not use your primary calendar. Keep the OAuth JSON out of source control; the refresh token is saved in your operating system keychain. `calendardb logout` removes that saved authorization.
|
|
30
|
+
|
|
31
|
+
## Use it
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from datetime import datetime, timedelta, timezone
|
|
35
|
+
|
|
36
|
+
from calendardb import Client
|
|
37
|
+
|
|
38
|
+
db = Client(calendar="CalendarDB Demo")
|
|
39
|
+
now = datetime.now(timezone.utc)
|
|
40
|
+
db.put({"service": "api", "series": "latency_ms", "value": 95, "_ts": now})
|
|
41
|
+
rows = db.query(after=now, before=now + timedelta(seconds=1), series="latency_ms")
|
|
42
|
+
print(rows[0]["value"])
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`put` adds an aware UTC `_ts` timestamp and UUID `_id` when they are missing. `query` returns records in timestamp order; `after` includes its timestamp and `before` excludes its timestamp. `contains` searches serialized record text with a case-sensitive local match. `calendar_query` passes a text search to Google Calendar, and `series` matches the `series` field exactly. Buffered writes flush at 50 records or after five seconds by default, with one Calendar event per record. IDs are immutable: retrying the same ID and payload is safe; writing different data under an existing ID fails. `get(id)` reads a record and `delete(id)` removes only a verified CalendarDB event.
|
|
46
|
+
|
|
47
|
+
For standard Python logging, use `CalendarHandler` from `calendardb.logging_handler`. For OpenTelemetry, install the `otel` extra and configure `CalendarSpanExporter` from `calendardb.otel` as a span exporter. These are ordinary records under the same timestamp and event model; the Calendar UI does not become an observability dashboard by wishing very hard.
|
|
48
|
+
|
|
49
|
+
## What the calendar means
|
|
50
|
+
|
|
51
|
+
The event start time is the record timestamp. Each event description contains the record, and each summary is a short readable preview. The `series` field is just a record dimension. Calendar labels are not used, and CalendarDB does not create a database index: the event start time is the timestamp index, the event rows are the records, and the dedicated calendar is the collection.
|
|
52
|
+
|
|
53
|
+
Google's published account storage allowance lists Gmail, Drive, and Photos; Calendar is not listed there. The careful reading is that event text is outside that listed allowance, not that CalendarDB has unlimited storage. Attachments stored in Drive still count toward Drive storage. Calendar API quotas, general usage limits, and operational limits still apply. Google's current published API quotas for projects created on or after May 1, 2026 are 10,000 requests per minute per project and 600 per minute per user per project; the page describes a 1,000,000-request daily threshold before charges and says further billing details are planned for later in 2026. Google also applies undisclosed single-calendar write limits. Its [paid Workspace guidance](https://knowledge.workspace.google.com/admin/calendar/avoid-calendar-use-limits) warns about creating over 100,000 events or more than 60 calendars in a short period; stricter account limits are not fully public. These are activity limits, not a published total storage capacity.
|
|
54
|
+
|
|
55
|
+
CalendarDB is a satire/demo project. Google Calendar is a calendar, and event-based storage inherits calendar-shaped limits and ergonomics. A day view is not a query planner.
|
|
56
|
+
|
|
57
|
+
## Project
|
|
58
|
+
|
|
59
|
+
Adapted from [GranolaDB](https://github.com/that-guy-wade/granoladb), with Google Calendar events as the storage backend.
|
|
60
|
+
|
|
61
|
+
See [the demo walkthrough](docs/demo.md) and [launch copy](launch-thread.md). The demo values are synthetic and marked `calendardb-demo-v1`; they are not captured from a real service.
|
|
62
|
+
|
|
63
|
+
References: [Calendar API quotas and usage limits](https://developers.google.com/workspace/calendar/api/guides/quota), [Google storage policy](https://support.google.com/googleone/answer/6374270), [Gemini Apps and Google Calendar](https://support.google.com/gemini/answer/15305236).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# CalendarDB demo: the incident has a day view
|
|
2
|
+
|
|
3
|
+
This demo writes twelve clearly marked, synthetic API measurements to a dedicated Google Calendar. Six `latency_ms` events and six `errors` events share the same timestamps at ten-minute intervals from 09:00 through 09:50 UTC. The latency values are `90, 95, 110, 480, 120, 100`; the error values are `0, 0, 1, 12, 1, 0`. Every record carries `_demo: calendardb-demo-v1`, `service: api`, a `series` name, and a deterministic `_id` derived from the UTC date, series, and time.
|
|
4
|
+
|
|
5
|
+
Run the local version first:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install -e '.[otel]'
|
|
9
|
+
python examples/seed_database.py --dry-run
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Dry-run follows the same seed and query path as live mode, using an in-memory `FakeBackend`. It checks that the backend contains exactly the 12 expected rows for today's demo, that every record matches the expected payload, and that unrelated pre-existing records survive. Re-running the seed uses the same IDs and accepts an existing event only when its complete payload matches. A conflicting payload stops the seed rather than overwriting existing calendar data.
|
|
13
|
+
|
|
14
|
+
For a live Calendar run, create a Google Cloud project and enable **Google Calendar API** from **APIs & Services → Library**. Open **Google Auth Platform → Audience**, choose **External** for a personal test account, and keep the app in **Testing**. Add the Google account you will use under **Test users**. In **Data Access**, add these scopes:
|
|
15
|
+
|
|
16
|
+
- `https://www.googleapis.com/auth/calendar.app.created`
|
|
17
|
+
- `https://www.googleapis.com/auth/calendar.calendarlist.readonly`
|
|
18
|
+
|
|
19
|
+
Save the consent-screen changes. Open **Clients → Create client**, choose **Desktop app**, create the OAuth client, and download its JSON to a location outside the repository. This test project is limited to its listed test users; Google's current help says Testing projects allow up to 100 listed users and test-user authorizations expire after seven days. Then:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
calendardb login --client-secrets /path/to/client_secret.json
|
|
23
|
+
python examples/seed_database.py
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The consent flow requests `https://www.googleapis.com/auth/calendar.app.created` and `https://www.googleapis.com/auth/calendar.calendarlist.readonly`. These let CalendarDB create and manage the app-created demo calendar and find it again. The tool writes only to `CalendarDB Demo`, a secondary calendar. The refresh token goes into the OS keychain; `CALENDARDB_TOKEN` can supply a short-lived token for controlled automation. To remove keychain authorization, run `calendardb logout`.
|
|
27
|
+
|
|
28
|
+
Google's [OAuth setup guide](https://support.google.com/cloud/answer/13461325) documents External testing, adding test users, and creating credentials. Google's [OAuth audience guide](https://support.google.com/cloud/answer/15549945) documents the Testing user limit and seven-day test-user authorization lifetime. Console navigation labels can change; use the linked instructions if the labels differ.
|
|
29
|
+
|
|
30
|
+
## Record a 60-second demo
|
|
31
|
+
|
|
32
|
+
Use a Google account already added as a test user. Record the terminal and Calendar browser window; do not show OAuth client JSON or keychain contents. The demo writes only synthetic records to `CalendarDB Demo`.
|
|
33
|
+
|
|
34
|
+
| Time | Screen and action | Narration |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| 0–6s | Terminal: run `python examples/seed_database.py --dry-run`. Keep the verification line visible. | “CalendarDB stores time-series records as calendar events. This dry run uses synthetic data and verifies the query.” |
|
|
37
|
+
| 6–14s | Show the current code/example with `_demo`, `service`, `series`, `_ts`, and `value` fields. | “Each event carries a timestamp, a series name, and a value. These measurements are invented for the demo.” |
|
|
38
|
+
| 14–24s | Switch to Google Calendar Schedule view. Select `CalendarDB Demo`, navigate to the current UTC date, and show all twelve entries from 09:00 through 09:50 UTC. | “There are twelve one-second events: six latency samples and six error counts. The event start is the timestamp index.” |
|
|
39
|
+
| 24–34s | Point to the 09:30 events. Open one latency event to show its JSON description, then show the 09:30 error event. | “At 09:30 the synthetic latency is 480 milliseconds, alongside 12 errors. This is a crafted example, not a real incident.” |
|
|
40
|
+
| 34–44s | Terminal: run the live readback snippet or show the script output for the `[09:20, 09:40)` range. Keep its four matching synthetic records visible. | “The range query includes 09:20 and excludes 09:40. It returns four measurements.” |
|
|
41
|
+
| 44–52s | Return to Schedule view. Zoom out enough to show the complete timeline. | “The collection is a dedicated calendar. Rows are events. Calendar labels and database indexes are not involved.” |
|
|
42
|
+
| 52–60s | Show the dry-run success and this README's quota note. | “CalendarDB is satire: the day view is real, while Calendar quotas and usage limits still apply.” |
|
|
43
|
+
|
|
44
|
+
Optional Gemini shot: only record it after confirming the app is connected and an authorized test account can read `CalendarDB Demo`. Ask `@Google Calendar` to show events around 09:30. Do not narrate series grouping, peak calculation, or cross-event analytics as demonstrated; those behaviors are unverified. If authorization is missing, leave this shot out and keep the demo pending rather than depicting a successful result.
|
|
45
|
+
|
|
46
|
+
## Verify the timeline
|
|
47
|
+
|
|
48
|
+
Open Google Calendar's **Schedule** view, select `CalendarDB Demo`, and inspect the twelve one-second events from 09:00 to 09:50 UTC. The event rows are the records; their start times are the timestamp index. There are no labels or separate database indexes. The calendar itself is the collection, and `series` is a field in each record.
|
|
49
|
+
|
|
50
|
+
The script verifies the range `09:20 <= _ts < 09:40` returns four records: latency 110 and 480, and errors 1 and 12. The highest latency is 480 ms at 09:30; the matching 09:30 error count is 12. CalendarDB is not claiming that a real incident happened. These are fabricated measurements designed to make the query visible in Calendar.
|
|
51
|
+
|
|
52
|
+
Optional: if Google Calendar is connected in Gemini Apps, try asking `@Google Calendar` to show the events in the `CalendarDB Demo` calendar around 09:30 UTC. Google's Gemini help documents finding events and requesting counts for date ranges. It does not establish that this setup will reliably group these records by `series`, compare values across events, or calculate the peak; those analytics remain unverified. Check the returned Calendar events before relying on a response.
|
|
53
|
+
|
|
54
|
+
## Operational notes
|
|
55
|
+
|
|
56
|
+
CalendarDB buffers writes and creates one Calendar event for each record when it flushes. The default is 50 records or five seconds. Small batches are useful for a demo, while every event still consumes a Calendar API request. Google's quota page currently lists, for projects created on or after May 1, 2026, 10,000 API requests per minute per project and 600 per minute per user per project. It also lists a 1,000,000-request daily threshold before charges and says fuller billing details are planned for later in 2026. Existing projects may retain older quotas, and general Calendar usage and operational limits also apply. Do not read this as a no-quota or unlimited-storage promise.
|
|
57
|
+
|
|
58
|
+
Google's listed shared storage allowance covers Gmail, Drive, and Photos, not Calendar. Event text is outside that listed Gmail/Drive/Photos storage allowance. This is a narrow statement about the published list, not a claim that Calendar accepts unlimited data. If an event references an attachment stored in Drive, that attachment counts toward Drive storage.
|
|
59
|
+
|
|
60
|
+
The project includes a standard-library logging handler and an optional OpenTelemetry span exporter (`pip install -e '.[otel]'`). Both use the same record writer, so event volume and Calendar API limits apply to telemetry too.
|
|
61
|
+
|
|
62
|
+
References: [Calendar API quotas and usage limits](https://developers.google.com/workspace/calendar/api/guides/quota), [Google storage policy](https://support.google.com/googleone/answer/6374270), [Gemini Apps: create and manage Calendar events](https://support.google.com/gemini/answer/15305236).
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Seed and verify twelve synthetic CalendarDB API measurements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from datetime import date, datetime, time, timezone
|
|
7
|
+
import hashlib
|
|
8
|
+
|
|
9
|
+
from calendardb import Client, FakeBackend
|
|
10
|
+
|
|
11
|
+
DEMO_MARKER = "calendardb-demo-v1"
|
|
12
|
+
CALENDAR = "CalendarDB Demo"
|
|
13
|
+
TIMES = tuple(time(9, minute, tzinfo=timezone.utc) for minute in range(0, 60, 10))
|
|
14
|
+
SERIES = {"latency_ms": (90, 95, 110, 480, 120, 100), "errors": (0, 0, 1, 12, 1, 0)}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def records_for_demo(day: date | str | None = None) -> list[dict]:
|
|
18
|
+
if day is None:
|
|
19
|
+
day = datetime.now(timezone.utc).date()
|
|
20
|
+
if isinstance(day, str):
|
|
21
|
+
day = date.fromisoformat(day)
|
|
22
|
+
records = []
|
|
23
|
+
for series, values in SERIES.items():
|
|
24
|
+
for at, value in zip(TIMES, values, strict=True):
|
|
25
|
+
timestamp = datetime.combine(day, at)
|
|
26
|
+
stamp = timestamp.isoformat().replace("+00:00", "Z")
|
|
27
|
+
record_id = hashlib.sha256(f"{day.isoformat()}:{series}:{stamp}".encode()).hexdigest()
|
|
28
|
+
records.append(
|
|
29
|
+
{
|
|
30
|
+
"_id": record_id,
|
|
31
|
+
"_ts": stamp,
|
|
32
|
+
"_demo": DEMO_MARKER,
|
|
33
|
+
"service": "api",
|
|
34
|
+
"series": series,
|
|
35
|
+
"value": value,
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
return sorted(records, key=lambda record: (record["_ts"], record["series"]))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def seed(client: Client, day: date | str | None = None) -> dict:
|
|
42
|
+
expected = records_for_demo(day)
|
|
43
|
+
previous = {record["_id"]: record for record in client.query()}
|
|
44
|
+
for record in expected:
|
|
45
|
+
existing = previous.get(record["_id"])
|
|
46
|
+
if existing is not None and existing != record:
|
|
47
|
+
raise RuntimeError(f"Existing record conflicts with demo ID {record['_id']}.")
|
|
48
|
+
if existing is None:
|
|
49
|
+
client.put(record)
|
|
50
|
+
client.flush()
|
|
51
|
+
actual = client.query()
|
|
52
|
+
return verify(client, expected, actual, previous)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _record_order(record: dict) -> tuple:
|
|
56
|
+
return record["_ts"], record["series"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _verify_demo_rows(expected: list[dict], actual: list[dict]) -> list[dict]:
|
|
60
|
+
day_key = expected[0]["_ts"][:10]
|
|
61
|
+
demo_today = sorted(
|
|
62
|
+
(
|
|
63
|
+
row
|
|
64
|
+
for row in actual
|
|
65
|
+
if row.get("_demo") == DEMO_MARKER and row["_ts"].startswith(day_key)
|
|
66
|
+
),
|
|
67
|
+
key=_record_order,
|
|
68
|
+
)
|
|
69
|
+
if demo_today != expected:
|
|
70
|
+
raise RuntimeError("Seed readback mismatch: expected exactly twelve matching demo records.")
|
|
71
|
+
return demo_today
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _verify_window(client: Client, expected: list[dict], day_key: str) -> list[dict]:
|
|
75
|
+
start = f"{day_key}T09:20:00Z"
|
|
76
|
+
end = f"{day_key}T09:40:00Z"
|
|
77
|
+
window = client.query(after=start, before=end)
|
|
78
|
+
demo_window = sorted(
|
|
79
|
+
(row for row in window if row.get("_demo") == DEMO_MARKER), key=_record_order
|
|
80
|
+
)
|
|
81
|
+
expected_window = [row for row in expected if start <= row["_ts"] < end]
|
|
82
|
+
if demo_window != expected_window:
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
"Seed readback mismatch: expected four records in the 09:20–09:40 range."
|
|
85
|
+
)
|
|
86
|
+
return demo_window
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _verify_peak(demo_today: list[dict], day_key: str) -> dict:
|
|
90
|
+
latency_peak = max(
|
|
91
|
+
(row for row in demo_today if row["series"] == "latency_ms"), key=lambda row: row["value"]
|
|
92
|
+
)
|
|
93
|
+
paired_errors = next(
|
|
94
|
+
row for row in demo_today if row["series"] == "errors" and row["_ts"] == latency_peak["_ts"]
|
|
95
|
+
)
|
|
96
|
+
if (
|
|
97
|
+
latency_peak["value"] != 480
|
|
98
|
+
or latency_peak["_ts"] != f"{day_key}T09:30:00Z"
|
|
99
|
+
or paired_errors["value"] != 12
|
|
100
|
+
):
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
"Seed readback mismatch: expected the 480 ms peak and 12 errors at 09:30."
|
|
103
|
+
)
|
|
104
|
+
return latency_peak
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def verify(client: Client, expected: list[dict], actual: list[dict], previous: dict) -> dict:
|
|
108
|
+
day_key = expected[0]["_ts"][:10]
|
|
109
|
+
demo_today = _verify_demo_rows(expected, actual)
|
|
110
|
+
current = {row["_id"]: row for row in actual}
|
|
111
|
+
if any(current.get(record_id) != record for record_id, record in previous.items()):
|
|
112
|
+
raise RuntimeError("Seed readback mismatch: pre-existing records were not preserved.")
|
|
113
|
+
window = _verify_window(client, expected, day_key)
|
|
114
|
+
latency_peak = _verify_peak(demo_today, day_key)
|
|
115
|
+
return {
|
|
116
|
+
"records": expected,
|
|
117
|
+
"all_records": actual,
|
|
118
|
+
"window": window,
|
|
119
|
+
"latency_peak": latency_peak,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main(argv: list[str] | None = None) -> int:
|
|
124
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
125
|
+
parser.add_argument(
|
|
126
|
+
"--dry-run", action="store_true", help="seed and verify with an offline FakeBackend"
|
|
127
|
+
)
|
|
128
|
+
args = parser.parse_args(argv)
|
|
129
|
+
backend = FakeBackend() if args.dry_run else None
|
|
130
|
+
client = Client(backend=backend, calendar=CALENDAR)
|
|
131
|
+
result = seed(client)
|
|
132
|
+
mode = "offline FakeBackend" if args.dry_run else CALENDAR
|
|
133
|
+
print(
|
|
134
|
+
f"verified 12 synthetic records in {mode}; "
|
|
135
|
+
f"range=4 peak={result['latency_peak']['value']}ms at {result['latency_peak']['_ts']} "
|
|
136
|
+
f"errors=12"
|
|
137
|
+
)
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
1/5 CalendarDB: a time-series database with a day view. It stores each record as a private, transparent one-second Google Calendar event. Your query planner is a calendar.
|
|
2
|
+
|
|
3
|
+
2/5 The timestamp is the event start; the record is in the description. The calendar is the collection, event rows are records, and `series` is a field. No labels or database index required. (Because, technically, a calendar already has time.)
|
|
4
|
+
|
|
5
|
+
3/5 The synthetic demo seeds 12 API measurements. At 09:30, latency reaches 480 ms and errors hit 12. Query `[09:20, 09:40)` returns four rows. Then open Schedule view to admire the timestamp index.
|
|
6
|
+
|
|
7
|
+
4/5 Try it offline: `pip install -e '.[otel]'` then `python examples/seed_database.py --dry-run`. Live mode uses a dedicated app-created calendar after Google OAuth. The seed is deterministic and safe to rerun.
|
|
8
|
+
|
|
9
|
+
5/5 CalendarDB is satire, not unlimited storage or quota-free analytics. Calendar API limits still apply. Gemini can find Calendar events; grouping this demo by series or calculating its peak is unverified.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "calendardb"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Store your logs and agent traces in Google Calendar. Your database has a day view."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "CalendarDB" }]
|
|
13
|
+
keywords = ["observability", "logging", "database", "calendar", "satire"]
|
|
14
|
+
dependencies = ["google-auth>=2", "google-auth-oauthlib>=1", "keyring>=24"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/that-guy-wade/calendardb"
|
|
18
|
+
Repository = "https://github.com/that-guy-wade/calendardb"
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
otel = ["opentelemetry-sdk>=1.20"]
|
|
22
|
+
dev = ["pytest>=8", "ruff>=0.9", "radon>=6", "build>=1"]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
calendardb = "calendardb.cli:main"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/calendardb"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
pythonpath = ["src"]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
target-version = "py310"
|
|
36
|
+
line-length = 100
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["E4", "E7", "E9", "F", "C901"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff.lint.mccabe]
|
|
42
|
+
max-complexity = 10
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .backend import FakeBackend
|
|
2
|
+
from .client import Client
|
|
3
|
+
from .logging_handler import CalendarHandler
|
|
4
|
+
|
|
5
|
+
__all__ = ["Client", "FakeBackend", "CalendarHandler"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def __getattr__(name):
|
|
9
|
+
if name == "CalendarSpanExporter":
|
|
10
|
+
from .otel import CalendarSpanExporter
|
|
11
|
+
|
|
12
|
+
return CalendarSpanExporter
|
|
13
|
+
raise AttributeError(name)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Google OAuth credentials for CalendarDB."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import keyring
|
|
9
|
+
from google.auth.transport.requests import Request
|
|
10
|
+
from google.oauth2.credentials import Credentials
|
|
11
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
12
|
+
|
|
13
|
+
SERVICE_NAME = "calendardb"
|
|
14
|
+
KEYRING_ACCOUNT = "oauth"
|
|
15
|
+
TOKEN_ENV = "CALENDARDB_TOKEN"
|
|
16
|
+
SCOPES = [
|
|
17
|
+
"https://www.googleapis.com/auth/calendar.app.created",
|
|
18
|
+
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CalendarAuthError(Exception):
|
|
23
|
+
"""Safe-to-display authentication error."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _store(credentials: Credentials) -> None:
|
|
27
|
+
try:
|
|
28
|
+
keyring.set_password(SERVICE_NAME, KEYRING_ACCOUNT, credentials.to_json())
|
|
29
|
+
except Exception:
|
|
30
|
+
raise CalendarAuthError(
|
|
31
|
+
"Could not save CalendarDB authorization in the system keychain."
|
|
32
|
+
) from None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def login(client_secrets: str) -> None:
|
|
36
|
+
"""Run Google's installed-app consent flow and save credentials securely."""
|
|
37
|
+
try:
|
|
38
|
+
flow = InstalledAppFlow.from_client_secrets_file(client_secrets, SCOPES)
|
|
39
|
+
credentials = flow.run_local_server(
|
|
40
|
+
host="localhost",
|
|
41
|
+
port=0,
|
|
42
|
+
authorization_prompt_message="",
|
|
43
|
+
success_message="CalendarDB authorization complete. You can close this window.",
|
|
44
|
+
prompt="consent",
|
|
45
|
+
)
|
|
46
|
+
except Exception:
|
|
47
|
+
raise CalendarAuthError(
|
|
48
|
+
"Google authorization failed. Check the client-secrets file and try again."
|
|
49
|
+
) from None
|
|
50
|
+
if not credentials.refresh_token:
|
|
51
|
+
raise CalendarAuthError(
|
|
52
|
+
"Google did not grant a refresh token; authorize CalendarDB again with offline access."
|
|
53
|
+
)
|
|
54
|
+
_store(credentials)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def logout() -> None:
|
|
58
|
+
"""Remove CalendarDB's saved OAuth credentials from the system keychain."""
|
|
59
|
+
try:
|
|
60
|
+
keyring.delete_password(SERVICE_NAME, KEYRING_ACCOUNT)
|
|
61
|
+
except keyring.errors.PasswordDeleteError:
|
|
62
|
+
pass
|
|
63
|
+
except Exception:
|
|
64
|
+
raise CalendarAuthError(
|
|
65
|
+
"Could not remove CalendarDB authorization from the system keychain."
|
|
66
|
+
) from None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _cached_credentials() -> Credentials:
|
|
70
|
+
try:
|
|
71
|
+
serialized = keyring.get_password(SERVICE_NAME, KEYRING_ACCOUNT)
|
|
72
|
+
except Exception:
|
|
73
|
+
raise CalendarAuthError(
|
|
74
|
+
"Could not read CalendarDB authorization from the system keychain."
|
|
75
|
+
) from None
|
|
76
|
+
if not serialized:
|
|
77
|
+
raise CalendarAuthError(
|
|
78
|
+
"CalendarDB is not logged in. Run `calendardb login --client-secrets PATH`."
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
info = json.loads(serialized)
|
|
82
|
+
if not isinstance(info, dict):
|
|
83
|
+
raise ValueError
|
|
84
|
+
return Credentials.from_authorized_user_info(info, SCOPES)
|
|
85
|
+
except Exception:
|
|
86
|
+
raise CalendarAuthError(
|
|
87
|
+
"Saved CalendarDB authorization is invalid. Run `calendardb login --client-secrets PATH`."
|
|
88
|
+
) from None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_access_token() -> str:
|
|
92
|
+
"""Return a bearer token, refreshing and securely persisting OAuth as needed."""
|
|
93
|
+
token = os.environ.get(TOKEN_ENV)
|
|
94
|
+
if token:
|
|
95
|
+
return token
|
|
96
|
+
credentials = _cached_credentials()
|
|
97
|
+
if credentials.expired or not credentials.token:
|
|
98
|
+
if not credentials.refresh_token:
|
|
99
|
+
raise CalendarAuthError(
|
|
100
|
+
"Saved CalendarDB authorization expired. Run `calendardb login --client-secrets PATH`."
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
credentials.refresh(Request())
|
|
104
|
+
except Exception:
|
|
105
|
+
raise CalendarAuthError(
|
|
106
|
+
"Could not refresh CalendarDB authorization. Run `calendardb login --client-secrets PATH`."
|
|
107
|
+
) from None
|
|
108
|
+
_store(credentials)
|
|
109
|
+
if not credentials.valid:
|
|
110
|
+
raise CalendarAuthError(
|
|
111
|
+
"Saved CalendarDB authorization is unusable. Run `calendardb login --client-secrets PATH`."
|
|
112
|
+
)
|
|
113
|
+
return credentials.token
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""An offline backend for examples and tests."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
|
|
5
|
+
from .timestamps import in_range, parse_timestamp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakeBackend:
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self._records = {}
|
|
11
|
+
|
|
12
|
+
def create_record(self, record):
|
|
13
|
+
record_id = record["_id"]
|
|
14
|
+
if record_id in self._records and self._records[record_id] != record:
|
|
15
|
+
raise ValueError("Record ID already exists with different data")
|
|
16
|
+
self._records[record_id] = copy.deepcopy(record)
|
|
17
|
+
return record_id
|
|
18
|
+
|
|
19
|
+
def get_records(self, after=None, before=None, q=None):
|
|
20
|
+
if q:
|
|
21
|
+
raise NotImplementedError("Native Calendar search requires CalendarBackend")
|
|
22
|
+
start, end = [
|
|
23
|
+
None if value is None else parse_timestamp(value) for value in (after, before)
|
|
24
|
+
]
|
|
25
|
+
return [
|
|
26
|
+
copy.deepcopy(record)
|
|
27
|
+
for record in self._records.values()
|
|
28
|
+
if in_range(parse_timestamp(record["_ts"]), start, end)
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
def get_record(self, record_id):
|
|
32
|
+
return copy.deepcopy(self._records.get(record_id))
|
|
33
|
+
|
|
34
|
+
def delete_record(self, record_id):
|
|
35
|
+
self._records.pop(record_id, None)
|