fromscratch-pytsdb 0.4.0__py3-none-any.whl
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.
- fromscratch_pytsdb-0.4.0.dist-info/METADATA +159 -0
- fromscratch_pytsdb-0.4.0.dist-info/RECORD +17 -0
- fromscratch_pytsdb-0.4.0.dist-info/WHEEL +4 -0
- fromscratch_pytsdb-0.4.0.dist-info/licenses/LICENSE +21 -0
- pytsdb/__init__.py +69 -0
- pytsdb/_version.py +24 -0
- pytsdb/aggregation.py +140 -0
- pytsdb/atomic.py +66 -0
- pytsdb/database.py +350 -0
- pytsdb/datapoint.py +142 -0
- pytsdb/downsampling.py +51 -0
- pytsdb/errors.py +62 -0
- pytsdb/persistence.py +81 -0
- pytsdb/py.typed +0 -0
- pytsdb/query.py +92 -0
- pytsdb/retention.py +99 -0
- pytsdb/series.py +192 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fromscratch-pytsdb
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: An educational time-series database library built from scratch in Python
|
|
5
|
+
Project-URL: Homepage, https://github.com/robcox2910/pytsdb
|
|
6
|
+
Project-URL: Repository, https://github.com/robcox2910/pytsdb
|
|
7
|
+
Project-URL: Issues, https://github.com/robcox2910/pytsdb/issues
|
|
8
|
+
Project-URL: Documentation, https://robcox2910.github.io/pytsdb/
|
|
9
|
+
Author: Rob Cox
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: database,education,metrics,time-series
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Education
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Education
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.14
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: commitizen>=4.4.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: hypothesis>=6.170; extra == 'dev'
|
|
25
|
+
Requires-Dist: pip-audit>=2.10.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pre-commit>=4.0.1; extra == 'dev'
|
|
27
|
+
Requires-Dist: pyright>=1.1.400; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.3.4; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.9.3; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# pytsdb
|
|
34
|
+
|
|
35
|
+
An educational time-series database built from scratch in Python. Part of the **PyLearn** platform for kids aged 12+.
|
|
36
|
+
|
|
37
|
+
A time-series database stores data points that each have a **timestamp**, a **value**, and optional **tags** -- like a weather station logbook that records temperature readings throughout the day.
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- **DataPoint** -- timestamped values with optional tags (e.g. sensor="kitchen")
|
|
42
|
+
- **TimeSeries** -- ordered collection of data points for one metric
|
|
43
|
+
- **TimeSeriesDB** -- container managing multiple named series with full CRUD
|
|
44
|
+
- **Time Range Queries** -- efficient binary-search lookups between two timestamps
|
|
45
|
+
- **Tag Filtering** -- filter data points by tag key-value pairs
|
|
46
|
+
- **Aggregation** -- compute avg, min, max, sum, count over configurable time windows
|
|
47
|
+
- **Downsampling** -- reduce resolution (e.g. minute data to hourly averages)
|
|
48
|
+
- **Retention Policies** -- auto-delete data older than N days
|
|
49
|
+
- **Persistence** -- save/load the entire database to JSON files
|
|
50
|
+
- **Rate of Change** -- compute how fast a value is changing over time
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from datetime import datetime, timedelta, timezone
|
|
56
|
+
from pytsdb import AggFunc, TimeSeriesDB
|
|
57
|
+
|
|
58
|
+
# Create a database and a series
|
|
59
|
+
db = TimeSeriesDB()
|
|
60
|
+
db.create_series("temperature")
|
|
61
|
+
|
|
62
|
+
# Add some readings
|
|
63
|
+
db.add_point("temperature", value=68.0,
|
|
64
|
+
timestamp=datetime(2024, 7, 4, 8, 0, tzinfo=timezone.utc),
|
|
65
|
+
tags={"sensor": "kitchen"})
|
|
66
|
+
db.add_point("temperature", value=72.5,
|
|
67
|
+
timestamp=datetime(2024, 7, 4, 10, 0, tzinfo=timezone.utc),
|
|
68
|
+
tags={"sensor": "kitchen"})
|
|
69
|
+
db.add_point("temperature", value=75.0,
|
|
70
|
+
timestamp=datetime(2024, 7, 4, 12, 0, tzinfo=timezone.utc),
|
|
71
|
+
tags={"sensor": "bedroom"})
|
|
72
|
+
|
|
73
|
+
# Query by time range and tags
|
|
74
|
+
kitchen = db.query("temperature", tags={"sensor": "kitchen"})
|
|
75
|
+
print(f"Kitchen readings: {len(kitchen)}") # 2
|
|
76
|
+
|
|
77
|
+
# Aggregate: hourly averages
|
|
78
|
+
hourly = db.aggregate("temperature", window=timedelta(hours=1), func=AggFunc.AVG)
|
|
79
|
+
|
|
80
|
+
# Downsample to 2-hour buckets
|
|
81
|
+
coarse = db.downsample("temperature", target_interval=timedelta(hours=2))
|
|
82
|
+
|
|
83
|
+
# Auto-expire old data
|
|
84
|
+
db.set_retention("temperature", max_age=timedelta(days=30))
|
|
85
|
+
db.apply_retention()
|
|
86
|
+
|
|
87
|
+
# Save to disk
|
|
88
|
+
from pytsdb import save, load
|
|
89
|
+
save(db, "my_data.json")
|
|
90
|
+
db2 = load("my_data.json")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Concepts
|
|
94
|
+
|
|
95
|
+
See the [docs/](docs/) folder for kid-friendly explanations with real-world analogies:
|
|
96
|
+
|
|
97
|
+
- [What is a Time-Series DB?](docs/index.md) -- weather station logbook analogy
|
|
98
|
+
- [Data Points](docs/concepts/datapoints.md) -- thermometer readings
|
|
99
|
+
- [Time Series](docs/concepts/series.md) -- a week of readings
|
|
100
|
+
- [Queries](docs/concepts/queries.md) -- checking yesterday's weather
|
|
101
|
+
- [Aggregation](docs/concepts/aggregation.md) -- weekly average temperature
|
|
102
|
+
- [Downsampling](docs/concepts/downsampling.md) -- monthly summaries
|
|
103
|
+
- [Retention](docs/concepts/retention.md) -- throwing away old newspapers
|
|
104
|
+
|
|
105
|
+
<!-- docs:start -->
|
|
106
|
+
## Documentation
|
|
107
|
+
|
|
108
|
+
Full docs (with kid-friendly explanations of every idea): https://robcox2910.github.io/pytsdb/
|
|
109
|
+
<!-- docs:end -->
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
uv sync --all-extras
|
|
115
|
+
uv run ruff check src/ tests/
|
|
116
|
+
uv run ruff format src/ tests/
|
|
117
|
+
uv run pyright src tests
|
|
118
|
+
uv run pytest
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Releasing
|
|
122
|
+
|
|
123
|
+
Other projects in the series ([PyStack](https://github.com/robcox2910/pystack)) use a pinned release of this one,
|
|
124
|
+
not whatever is on `main`. To publish a change for them:
|
|
125
|
+
|
|
126
|
+
1. Merge the change to `main`.
|
|
127
|
+
2. Tag it: `git tag -a vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z`.
|
|
128
|
+
A change to the public API bumps the minor version (`v0.1.0` → `v0.2.0`);
|
|
129
|
+
anything else bumps the patch (`v0.1.0` → `v0.1.1`).
|
|
130
|
+
3. In each project that uses it, edit the tag in `[tool.uv.sources]`, run
|
|
131
|
+
`uv lock --upgrade-package <name>`, and open a pull request so CI checks
|
|
132
|
+
the pair together.
|
|
133
|
+
|
|
134
|
+
<!-- series:start -->
|
|
135
|
+
## Related Projects
|
|
136
|
+
|
|
137
|
+
This project is one of a series that builds real systems "from scratch in Python" to teach how they work. See the [series home](https://robcox2910.github.io/) for the full story.
|
|
138
|
+
|
|
139
|
+
| Project | What it teaches | Repository | Docs |
|
|
140
|
+
| --- | --- | --- | --- |
|
|
141
|
+
| PyOS | Operating systems | [py-os](https://github.com/robcox2910/py-os) | [Docs](https://robcox2910.github.io/py-os/) |
|
|
142
|
+
| Pebble | Compilers and programming languages | [pebble-lang](https://github.com/robcox2910/pebble-lang) | [Docs](https://robcox2910.github.io/pebble-lang/) |
|
|
143
|
+
| PyDB | Relational databases | [pydb](https://github.com/robcox2910/pydb) | [Docs](https://robcox2910.github.io/pydb/) |
|
|
144
|
+
| PyKV | Key-value stores | [pykv](https://github.com/robcox2910/pykv) | [Docs](https://robcox2910.github.io/pykv/) |
|
|
145
|
+
| PyDocDB | Document databases | [pydoc-db](https://github.com/robcox2910/pydoc-db) | [Docs](https://robcox2910.github.io/pydoc-db/) |
|
|
146
|
+
| PyGraphDB | Graph databases | [pygraphdb](https://github.com/robcox2910/pygraphdb) | [Docs](https://robcox2910.github.io/pygraphdb/) |
|
|
147
|
+
| **PyTSDB** | **Time-series databases** | [pytsdb](https://github.com/robcox2910/pytsdb) | [Docs](https://robcox2910.github.io/pytsdb/) |
|
|
148
|
+
| PyVecDB | Vector databases | [pyvecdb](https://github.com/robcox2910/pyvecdb) | [Docs](https://robcox2910.github.io/pyvecdb/) |
|
|
149
|
+
| PyColDB | Column-family databases | [pycoldb](https://github.com/robcox2910/pycoldb) | [Docs](https://robcox2910.github.io/pycoldb/) |
|
|
150
|
+
| PyCrypt | Cryptography | [pycrypt](https://github.com/robcox2910/pycrypt) | [Docs](https://robcox2910.github.io/pycrypt/) |
|
|
151
|
+
| PyNet | Computer networking | [pynet](https://github.com/robcox2910/pynet) | [Docs](https://robcox2910.github.io/pynet/) |
|
|
152
|
+
| PyWeb | HTTP web servers | [pyweb](https://github.com/robcox2910/pyweb) | [Docs](https://robcox2910.github.io/pyweb/) |
|
|
153
|
+
| PyGit | Version control | [pygit](https://github.com/robcox2910/pygit) | [Docs](https://robcox2910.github.io/pygit/) |
|
|
154
|
+
| PySearch | Search engines | [pysearch](https://github.com/robcox2910/pysearch) | [Docs](https://robcox2910.github.io/pysearch/) |
|
|
155
|
+
| PyMQ | Message queues | [pymq](https://github.com/robcox2910/pymq) | [Docs](https://robcox2910.github.io/pymq/) |
|
|
156
|
+
| PyLLM | Language models (the brain) | [pyllm](https://github.com/robcox2910/pyllm) | [Docs](https://robcox2910.github.io/pyllm/) |
|
|
157
|
+
| PyScheduler | Job scheduling | [pyscheduler](https://github.com/robcox2910/pyscheduler) | [Docs](https://robcox2910.github.io/pyscheduler/) |
|
|
158
|
+
| PyStack | Full-stack integration | [pystack](https://github.com/robcox2910/pystack) | [Docs](https://robcox2910.github.io/pystack/) |
|
|
159
|
+
<!-- series:end -->
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
pytsdb/__init__.py,sha256=iYrRS4Y8YvmyHUIx77XP2KvYbHG8KLPEt-OMt6aVExg,2035
|
|
2
|
+
pytsdb/_version.py,sha256=HGBlVvZpQ8N2kh1n_LY5PM4s9ljcIgdbOCgXN-uFje4,520
|
|
3
|
+
pytsdb/aggregation.py,sha256=b1I8_tmJKQL3uUahlnRF0aEu8p758NexhXPcCVvrLdM,4432
|
|
4
|
+
pytsdb/atomic.py,sha256=QLLPGZqpA-TgVV5lDEKCLB7vVr05bxj97_Fwykz_gi4,2345
|
|
5
|
+
pytsdb/database.py,sha256=AqbVEPj4TBkNQSsGzKtfsnwtzZrJUBXYN1tSI76p9e8,11868
|
|
6
|
+
pytsdb/datapoint.py,sha256=6XprVlSwZdVreeqbaoakUXbQifWbYM28JgNY4IPVQ5k,5254
|
|
7
|
+
pytsdb/downsampling.py,sha256=PkWmXCZDyWfirfOWJnKjRZpccVJe-3MaSjhZf7XzDzA,1614
|
|
8
|
+
pytsdb/errors.py,sha256=AQI2DMqCJxUYmmFlYXDe45jvVGsl0JPt27tAykOf-Hs,1726
|
|
9
|
+
pytsdb/persistence.py,sha256=IpIyCw98xyWnFkTN0afIBNn49InCWxltd16jwle7eSM,3031
|
|
10
|
+
pytsdb/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
pytsdb/query.py,sha256=wY8tZZSoFXR46CaGIUBrkIhuN0J4b3uL9cYBO03kuEk,2590
|
|
12
|
+
pytsdb/retention.py,sha256=JFObJg5xgu-YQoOZcRZImGchZx3S6Sj6ofpCqKLv-dI,3181
|
|
13
|
+
pytsdb/series.py,sha256=f5gFbOao-7KWflQQ7LVknzZrPwPKSrxWN8HED5MZaTg,6231
|
|
14
|
+
fromscratch_pytsdb-0.4.0.dist-info/METADATA,sha256=xW1m8v1s6lxHuM4ZGiG0A_YGXLIxITTm7sxopaR4r0M,7821
|
|
15
|
+
fromscratch_pytsdb-0.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
16
|
+
fromscratch_pytsdb-0.4.0.dist-info/licenses/LICENSE,sha256=NtHM2ak--oE71VCcY50kcCoC1cKS0LRUsxq_xA89RNc,1064
|
|
17
|
+
fromscratch_pytsdb-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rob Cox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pytsdb/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""pytsdb -- an educational time-series database.
|
|
2
|
+
|
|
3
|
+
A time-series database stores data points that each have a **timestamp**,
|
|
4
|
+
a **value**, and optional **tags**. Think of it like a weather station's
|
|
5
|
+
logbook: every few minutes you jot down the temperature, humidity, and
|
|
6
|
+
wind speed along with the current time.
|
|
7
|
+
|
|
8
|
+
Quick start::
|
|
9
|
+
|
|
10
|
+
from pytsdb import TimeSeriesDB
|
|
11
|
+
|
|
12
|
+
db = TimeSeriesDB()
|
|
13
|
+
db.create_series("temperature")
|
|
14
|
+
db.add_point("temperature", value=72.5, tags={"sensor": "kitchen"})
|
|
15
|
+
|
|
16
|
+
points = db.query("temperature")
|
|
17
|
+
print(points)
|
|
18
|
+
|
|
19
|
+
Modules:
|
|
20
|
+
datapoint -- A single timestamped reading.
|
|
21
|
+
series -- An ordered collection of readings for one metric.
|
|
22
|
+
database -- A container that manages multiple series.
|
|
23
|
+
query -- Time-range and tag-based lookups.
|
|
24
|
+
aggregation -- Windowed summaries (avg, min, max, sum, count).
|
|
25
|
+
downsampling -- Resolution reduction (e.g. minutes to hours).
|
|
26
|
+
retention -- Auto-expiry of old data.
|
|
27
|
+
persistence -- Save / load the database to JSON files.
|
|
28
|
+
errors -- Custom exception classes.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from pytsdb.aggregation import AggFunc, aggregate
|
|
32
|
+
from pytsdb.database import TimeSeriesDB
|
|
33
|
+
from pytsdb.datapoint import DataPoint
|
|
34
|
+
from pytsdb.downsampling import downsample
|
|
35
|
+
from pytsdb.errors import (
|
|
36
|
+
DuplicateSeriesError,
|
|
37
|
+
EmptySeriesError,
|
|
38
|
+
InvalidRetentionError,
|
|
39
|
+
InvalidWindowError,
|
|
40
|
+
PersistenceError,
|
|
41
|
+
PyTSDBError,
|
|
42
|
+
SeriesNotFoundError,
|
|
43
|
+
)
|
|
44
|
+
from pytsdb.persistence import load, save
|
|
45
|
+
from pytsdb.query import filter_by_tags, query, query_range
|
|
46
|
+
from pytsdb.retention import RetentionPolicy
|
|
47
|
+
from pytsdb.series import TimeSeries
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"AggFunc",
|
|
51
|
+
"DataPoint",
|
|
52
|
+
"DuplicateSeriesError",
|
|
53
|
+
"EmptySeriesError",
|
|
54
|
+
"InvalidRetentionError",
|
|
55
|
+
"InvalidWindowError",
|
|
56
|
+
"PersistenceError",
|
|
57
|
+
"PyTSDBError",
|
|
58
|
+
"RetentionPolicy",
|
|
59
|
+
"SeriesNotFoundError",
|
|
60
|
+
"TimeSeries",
|
|
61
|
+
"TimeSeriesDB",
|
|
62
|
+
"aggregate",
|
|
63
|
+
"downsample",
|
|
64
|
+
"filter_by_tags",
|
|
65
|
+
"load",
|
|
66
|
+
"query",
|
|
67
|
+
"query_range",
|
|
68
|
+
"save",
|
|
69
|
+
]
|
pytsdb/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
pytsdb/aggregation.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Windowed aggregation functions.
|
|
2
|
+
|
|
3
|
+
Suppose you recorded the temperature every minute for a whole day.
|
|
4
|
+
That's 1,440 readings! Aggregation lets you *summarise* them:
|
|
5
|
+
|
|
6
|
+
- "What was the **average** temperature each hour?"
|
|
7
|
+
- "What was the **highest** reading today?"
|
|
8
|
+
|
|
9
|
+
You pick a *window* (like one hour) and a *function* (like average),
|
|
10
|
+
and the aggregator crunches the numbers for you.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from datetime import UTC, datetime, timedelta
|
|
15
|
+
from enum import Enum
|
|
16
|
+
|
|
17
|
+
from pytsdb.datapoint import DataPoint
|
|
18
|
+
from pytsdb.errors import EmptySeriesError, InvalidWindowError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AggFunc(Enum):
|
|
22
|
+
"""Supported aggregation functions.
|
|
23
|
+
|
|
24
|
+
Each value names a way to summarise a group of numbers.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
AVG = "avg"
|
|
28
|
+
MIN = "min"
|
|
29
|
+
MAX = "max"
|
|
30
|
+
SUM = "sum"
|
|
31
|
+
COUNT = "count"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _bucket_key(timestamp: datetime, window: timedelta) -> datetime:
|
|
35
|
+
"""Return the start of the time bucket that *timestamp* falls into.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
timestamp: The point in time to bucket.
|
|
39
|
+
window: The size of each bucket.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
A UTC datetime representing the start of the bucket.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
InvalidWindowError: If the bucket would start before year 1.
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
epoch = datetime(1970, 1, 1, tzinfo=UTC)
|
|
49
|
+
# Count whole windows with timedelta arithmetic, which is exact to the
|
|
50
|
+
# microsecond. Going through float seconds rounded, and could label a
|
|
51
|
+
# bucket a hair *after* its first reading -- or put a reading that sits
|
|
52
|
+
# exactly on a boundary into the bucket before it.
|
|
53
|
+
windows_since_epoch = (timestamp - epoch) // window
|
|
54
|
+
try:
|
|
55
|
+
return epoch + windows_since_epoch * window
|
|
56
|
+
except OverflowError:
|
|
57
|
+
# Buckets line up with 1970-01-01, so a reading in the first window
|
|
58
|
+
# of the calendar can need a bucket starting before 0001-01-01, the
|
|
59
|
+
# earliest moment a datetime can hold.
|
|
60
|
+
msg = (
|
|
61
|
+
f"A {window} window puts the bucket for {timestamp.isoformat()} "
|
|
62
|
+
"before year 1, which a datetime can't hold -- use a smaller window"
|
|
63
|
+
)
|
|
64
|
+
raise InvalidWindowError(msg) from None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def aggregate(
|
|
68
|
+
points: list[DataPoint],
|
|
69
|
+
window: timedelta,
|
|
70
|
+
func: AggFunc = AggFunc.AVG,
|
|
71
|
+
) -> list[DataPoint]:
|
|
72
|
+
"""Group points into time windows and compute a summary value.
|
|
73
|
+
|
|
74
|
+
Think of it like sorting your marbles into jars by colour
|
|
75
|
+
and then counting how many are in each jar.
|
|
76
|
+
|
|
77
|
+
Buckets line up with midnight on 1 January 1970, counting whole
|
|
78
|
+
windows forwards or backwards from there.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
points: Sorted data points to aggregate.
|
|
82
|
+
window: The size of each time bucket (e.g. ``timedelta(hours=1)``).
|
|
83
|
+
func: Which summary to compute (avg, min, max, sum, count).
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
One :class:`~pytsdb.datapoint.DataPoint` per bucket, timestamped
|
|
87
|
+
at the bucket start, with the aggregated value.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
EmptySeriesError: If *points* is empty.
|
|
91
|
+
InvalidWindowError: If *window* is zero or negative, or if a
|
|
92
|
+
point's bucket would start before year 1 (only possible for a
|
|
93
|
+
point less than one window after 0001-01-01 00:00).
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
if not points:
|
|
97
|
+
msg = "Cannot aggregate an empty list of points"
|
|
98
|
+
raise EmptySeriesError(msg)
|
|
99
|
+
|
|
100
|
+
if window.total_seconds() <= 0:
|
|
101
|
+
msg = "Window must be a positive duration"
|
|
102
|
+
raise InvalidWindowError(msg)
|
|
103
|
+
|
|
104
|
+
# Group points by bucket
|
|
105
|
+
buckets: dict[datetime, list[float]] = defaultdict(list)
|
|
106
|
+
for point in points:
|
|
107
|
+
key = _bucket_key(point.timestamp, window)
|
|
108
|
+
buckets[key].append(point.value)
|
|
109
|
+
|
|
110
|
+
# Compute aggregate per bucket
|
|
111
|
+
result: list[DataPoint] = []
|
|
112
|
+
for bucket_ts in sorted(buckets):
|
|
113
|
+
values = buckets[bucket_ts]
|
|
114
|
+
agg_value = _compute(values, func)
|
|
115
|
+
result.append(DataPoint(timestamp=bucket_ts, value=agg_value))
|
|
116
|
+
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _compute(values: list[float], func: AggFunc) -> float:
|
|
121
|
+
"""Apply an aggregation function to a list of numbers.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
values: Non-empty list of floats.
|
|
125
|
+
func: Which aggregation to apply.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The aggregated result.
|
|
129
|
+
|
|
130
|
+
"""
|
|
131
|
+
if func is AggFunc.AVG:
|
|
132
|
+
return sum(values) / len(values)
|
|
133
|
+
if func is AggFunc.MIN:
|
|
134
|
+
return min(values)
|
|
135
|
+
if func is AggFunc.MAX:
|
|
136
|
+
return max(values)
|
|
137
|
+
if func is AggFunc.SUM:
|
|
138
|
+
return sum(values)
|
|
139
|
+
# COUNT
|
|
140
|
+
return float(len(values))
|
pytsdb/atomic.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Safe saving -- never leave a half-written file behind.
|
|
2
|
+
|
|
3
|
+
The usual way to save a file is: open it, wipe it, write the new
|
|
4
|
+
contents. If the computer crashes after the wipe but before the writing
|
|
5
|
+
finishes, your data is gone -- the old version was wiped and the new one
|
|
6
|
+
never arrived.
|
|
7
|
+
|
|
8
|
+
So we save like a careful student copying out their homework:
|
|
9
|
+
|
|
10
|
+
1. Write the new version on a **fresh sheet** (a temporary file sitting
|
|
11
|
+
right next to the real one).
|
|
12
|
+
2. Make sure the **ink is dry** -- ``fsync`` forces the bytes out of the
|
|
13
|
+
computer's memory and onto the disk.
|
|
14
|
+
3. **Swap** the fresh sheet in for the old one in a single instant step
|
|
15
|
+
(``Path.replace``). There is never a moment with no complete file: you
|
|
16
|
+
see either the old version or the new one, never half of each.
|
|
17
|
+
|
|
18
|
+
Real databases (SQLite, PostgreSQL, Redis) all use this same trick.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import os
|
|
23
|
+
import stat
|
|
24
|
+
import tempfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
# Permissions for a brand-new file: owner can read/write, others can read.
|
|
28
|
+
_NEW_FILE_MODE = 0o644
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def atomic_write_text(path: Path, text: str) -> None:
|
|
32
|
+
"""Replace the file at *path* with *text*, so a crash can never leave it half-written.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
path: The file to create or replace (its folder must already exist).
|
|
36
|
+
text: The new contents, saved as UTF-8.
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else _NEW_FILE_MODE
|
|
40
|
+
fd, temp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
41
|
+
temp_path = Path(temp_name)
|
|
42
|
+
try:
|
|
43
|
+
with os.fdopen(fd, "w", encoding="utf-8") as temp_file:
|
|
44
|
+
temp_file.write(text)
|
|
45
|
+
temp_file.flush()
|
|
46
|
+
os.fsync(temp_file.fileno())
|
|
47
|
+
temp_path.chmod(mode)
|
|
48
|
+
temp_path.replace(path)
|
|
49
|
+
except BaseException:
|
|
50
|
+
temp_path.unlink(missing_ok=True) # don't leave the scrap sheet lying around
|
|
51
|
+
raise
|
|
52
|
+
_sync_folder(path.parent)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _sync_folder(folder: Path) -> None:
|
|
56
|
+
"""Make the swap itself durable by syncing the folder's list of files.
|
|
57
|
+
|
|
58
|
+
Some systems (like Windows) can't sync a folder; there the swap is
|
|
59
|
+
still safe, just not guaranteed to survive a power cut.
|
|
60
|
+
"""
|
|
61
|
+
with contextlib.suppress(OSError):
|
|
62
|
+
fd = os.open(folder, os.O_RDONLY)
|
|
63
|
+
try:
|
|
64
|
+
os.fsync(fd)
|
|
65
|
+
finally:
|
|
66
|
+
os.close(fd)
|