arrayfile 0.0.1__tar.gz → 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.
- {arrayfile-0.0.1 → arrayfile-0.1.0}/PKG-INFO +21 -6
- {arrayfile-0.0.1 → arrayfile-0.1.0}/README.md +20 -5
- {arrayfile-0.0.1 → arrayfile-0.1.0}/pyproject.toml +1 -1
- {arrayfile-0.0.1 → arrayfile-0.1.0}/src/arrayfile/array.py +89 -29
- {arrayfile-0.0.1 → arrayfile-0.1.0}/LICENSE.md +0 -0
- {arrayfile-0.0.1 → arrayfile-0.1.0}/src/arrayfile/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arrayfile
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.1.0
|
|
4
4
|
Summary: Arrays backed by disk
|
|
5
5
|
Keywords: logging,terminal,scrollback,indexing
|
|
6
6
|
Author-email: Gareth Davidson <gaz@bitplane.net>
|
|
@@ -27,14 +27,14 @@ Requires-Dist: ruff ; extra == "dev"
|
|
|
27
27
|
Requires-Dist: pydoc-markdown ; extra == "dev"
|
|
28
28
|
Provides-Extra: dev
|
|
29
29
|
|
|
30
|
-
# arrayfile
|
|
30
|
+
# 📃 arrayfile
|
|
31
31
|
|
|
32
32
|
A file-backed numeric array using struct.pack. Does not support inserts or
|
|
33
33
|
slicing.
|
|
34
34
|
|
|
35
35
|
Smaller than relying on numpy though.
|
|
36
36
|
|
|
37
|
-
## Installation
|
|
37
|
+
## ▶️ Installation
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
40
|
pip install arrayfile
|
|
@@ -42,7 +42,7 @@ pip install arrayfile
|
|
|
42
42
|
|
|
43
43
|
## Usage
|
|
44
44
|
|
|
45
|
-
### Temporary Array
|
|
45
|
+
### 🗑 Temporary Array
|
|
46
46
|
|
|
47
47
|
This creates an array in your temp dir:
|
|
48
48
|
|
|
@@ -60,7 +60,7 @@ print(f"Values: {[arr[i] for i in range(len(arr))]}")
|
|
|
60
60
|
arr.close() # Clean up resources
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
### Persistent Array
|
|
63
|
+
### 💽 Persistent Array
|
|
64
64
|
|
|
65
65
|
You can use the same file, if you want to persist your data across sessions:
|
|
66
66
|
|
|
@@ -84,7 +84,7 @@ arr.append(2000)
|
|
|
84
84
|
arr.close()
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
## Context manager
|
|
87
|
+
## 🔧 Context manager
|
|
88
88
|
|
|
89
89
|
It has a finalizer in case you forget to call `close()`, but if you like to keep
|
|
90
90
|
your code tidy, you can use a context manager, like so:
|
|
@@ -101,4 +101,19 @@ with Array('d', 'measurements.array', 'w+b') as arr:
|
|
|
101
101
|
print(f" {i}: {value:.15f}")
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
+
## ⚖️ License
|
|
105
|
+
|
|
106
|
+
WTFPL with one extra clause:
|
|
107
|
+
|
|
108
|
+
1. Don't blame me.
|
|
109
|
+
|
|
110
|
+
Do what you like, but you're on your own.
|
|
111
|
+
|
|
112
|
+
## 🔗 Links
|
|
113
|
+
|
|
114
|
+
* [🏠 home](https://bitplane.net/dev/python/arrayfile)
|
|
115
|
+
* [📖 pydoc](https://bitplane.net/dev/python/arrayfile/pydoc)
|
|
116
|
+
* [🐍 pypi](https://pypi.org/project/arrayfile)
|
|
117
|
+
* [🐱 github](https://github.com/bitplane/arrayfile)
|
|
118
|
+
|
|
104
119
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
# arrayfile
|
|
1
|
+
# 📃 arrayfile
|
|
2
2
|
|
|
3
3
|
A file-backed numeric array using struct.pack. Does not support inserts or
|
|
4
4
|
slicing.
|
|
5
5
|
|
|
6
6
|
Smaller than relying on numpy though.
|
|
7
7
|
|
|
8
|
-
## Installation
|
|
8
|
+
## ▶️ Installation
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
pip install arrayfile
|
|
@@ -13,7 +13,7 @@ pip install arrayfile
|
|
|
13
13
|
|
|
14
14
|
## Usage
|
|
15
15
|
|
|
16
|
-
### Temporary Array
|
|
16
|
+
### 🗑 Temporary Array
|
|
17
17
|
|
|
18
18
|
This creates an array in your temp dir:
|
|
19
19
|
|
|
@@ -31,7 +31,7 @@ print(f"Values: {[arr[i] for i in range(len(arr))]}")
|
|
|
31
31
|
arr.close() # Clean up resources
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
### Persistent Array
|
|
34
|
+
### 💽 Persistent Array
|
|
35
35
|
|
|
36
36
|
You can use the same file, if you want to persist your data across sessions:
|
|
37
37
|
|
|
@@ -55,7 +55,7 @@ arr.append(2000)
|
|
|
55
55
|
arr.close()
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
## Context manager
|
|
58
|
+
## 🔧 Context manager
|
|
59
59
|
|
|
60
60
|
It has a finalizer in case you forget to call `close()`, but if you like to keep
|
|
61
61
|
your code tidy, you can use a context manager, like so:
|
|
@@ -72,3 +72,18 @@ with Array('d', 'measurements.array', 'w+b') as arr:
|
|
|
72
72
|
print(f" {i}: {value:.15f}")
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
## ⚖️ License
|
|
76
|
+
|
|
77
|
+
WTFPL with one extra clause:
|
|
78
|
+
|
|
79
|
+
1. Don't blame me.
|
|
80
|
+
|
|
81
|
+
Do what you like, but you're on your own.
|
|
82
|
+
|
|
83
|
+
## 🔗 Links
|
|
84
|
+
|
|
85
|
+
* [🏠 home](https://bitplane.net/dev/python/arrayfile)
|
|
86
|
+
* [📖 pydoc](https://bitplane.net/dev/python/arrayfile/pydoc)
|
|
87
|
+
* [🐍 pypi](https://pypi.org/project/arrayfile)
|
|
88
|
+
* [🐱 github](https://github.com/bitplane/arrayfile)
|
|
89
|
+
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import io
|
|
1
2
|
import mmap
|
|
2
3
|
import os
|
|
3
4
|
import struct
|
|
@@ -8,6 +9,7 @@ import weakref
|
|
|
8
9
|
|
|
9
10
|
class Array:
|
|
10
11
|
CHUNK_SIZE_BYTES = 4096
|
|
12
|
+
MAX_DTYPE_LENGTH = 8
|
|
11
13
|
|
|
12
14
|
# Header constants
|
|
13
15
|
MAGIC = b"ARYF"
|
|
@@ -20,6 +22,13 @@ class Array:
|
|
|
20
22
|
def __init__(self, dtype, filename=None, mode="r+b", initial_elements=0):
|
|
21
23
|
self._lock = threading.Lock()
|
|
22
24
|
|
|
25
|
+
try:
|
|
26
|
+
dtype_bytes = dtype.encode("ascii")
|
|
27
|
+
except (AttributeError, UnicodeEncodeError) as error:
|
|
28
|
+
raise ValueError("dtype must be an ASCII struct format string") from error
|
|
29
|
+
if len(dtype_bytes) > self.MAX_DTYPE_LENGTH:
|
|
30
|
+
raise ValueError(f"dtype must be at most {self.MAX_DTYPE_LENGTH} ASCII bytes")
|
|
31
|
+
|
|
23
32
|
if filename is None:
|
|
24
33
|
fd, filename = tempfile.mkstemp()
|
|
25
34
|
os.close(fd)
|
|
@@ -38,29 +47,46 @@ class Array:
|
|
|
38
47
|
|
|
39
48
|
if "w" in mode or not os.path.exists(filename):
|
|
40
49
|
# Create or truncate file
|
|
41
|
-
|
|
50
|
+
mode = "w+b"
|
|
51
|
+
self._file = open(filename, "w+b") # noqa: SIM115 - kept open for the Array lifetime
|
|
42
52
|
self._len = 0
|
|
43
53
|
self._allocate_capacity(initial_elements)
|
|
44
54
|
self._write_header()
|
|
45
55
|
else:
|
|
46
56
|
# Open existing file
|
|
47
|
-
self._file = open(filename, mode)
|
|
57
|
+
self._file = open(filename, mode) # noqa: SIM115 - kept open for the Array lifetime
|
|
48
58
|
if not self._read_header():
|
|
49
59
|
raise ValueError("File does not have a valid array header")
|
|
50
60
|
|
|
51
61
|
current_file_size = os.fstat(self._file.fileno()).st_size
|
|
52
62
|
data_size = current_file_size - self.HEADER_SIZE
|
|
63
|
+
required_data_size = self._len * self._element_size
|
|
64
|
+
if required_data_size > data_size:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"Array header declares {self._len} elements, but file only contains "
|
|
67
|
+
f"{data_size // self._element_size}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self._capacity_bytes = data_size
|
|
71
|
+
self._capacity = data_size // self._element_size
|
|
53
72
|
|
|
54
|
-
|
|
55
|
-
min_elements = (data_size + self._element_size - 1) // self._element_size
|
|
56
|
-
self._allocate_capacity(min_elements)
|
|
73
|
+
self._writable = any(flag in mode for flag in "+wa")
|
|
57
74
|
|
|
58
75
|
# Only mmap if the file has a non-zero size
|
|
59
76
|
if self._capacity_bytes > 0:
|
|
60
|
-
|
|
77
|
+
access = mmap.ACCESS_WRITE if self._writable else mmap.ACCESS_READ
|
|
78
|
+
self._mmap = mmap.mmap(self._file.fileno(), 0, access=access)
|
|
61
79
|
|
|
62
80
|
# Set up finalizer to ensure cleanup even if close() isn't called
|
|
63
|
-
self.
|
|
81
|
+
self._finalizer_state = {
|
|
82
|
+
"dtype": self._dtype,
|
|
83
|
+
"element_size": self._element_size,
|
|
84
|
+
"file": self._file,
|
|
85
|
+
"length": self._len,
|
|
86
|
+
"mmap": self._mmap,
|
|
87
|
+
"writable": self._writable,
|
|
88
|
+
}
|
|
89
|
+
self._finalizer = weakref.finalize(self, self._finalize_resources, self._finalizer_state)
|
|
64
90
|
|
|
65
91
|
def __len__(self):
|
|
66
92
|
return self._len
|
|
@@ -91,10 +117,42 @@ class Array:
|
|
|
91
117
|
except struct.error as e:
|
|
92
118
|
raise TypeError(f"Value {value} cannot be packed as {self._dtype_format}: {e}")
|
|
93
119
|
|
|
120
|
+
def _ensure_writable(self):
|
|
121
|
+
if not self._writable:
|
|
122
|
+
raise io.UnsupportedOperation("Array is not writable")
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def _finalize_resources(cls, state):
|
|
126
|
+
mapped_file = state["mmap"]
|
|
127
|
+
if mapped_file:
|
|
128
|
+
mapped_file.flush()
|
|
129
|
+
mapped_file.close()
|
|
130
|
+
|
|
131
|
+
file = state["file"]
|
|
132
|
+
if not file:
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
if state["writable"]:
|
|
136
|
+
dtype_bytes = state["dtype"].encode("ascii").ljust(cls.MAX_DTYPE_LENGTH, b"\x00")
|
|
137
|
+
header = struct.pack(
|
|
138
|
+
cls.HEADER_FORMAT,
|
|
139
|
+
cls.MAGIC,
|
|
140
|
+
cls.HEADER_VERSION,
|
|
141
|
+
len(state["dtype"]),
|
|
142
|
+
dtype_bytes,
|
|
143
|
+
state["element_size"],
|
|
144
|
+
state["length"],
|
|
145
|
+
)
|
|
146
|
+
file.seek(0)
|
|
147
|
+
file.write(header)
|
|
148
|
+
file.truncate(cls.HEADER_SIZE + state["length"] * state["element_size"])
|
|
149
|
+
file.flush()
|
|
150
|
+
|
|
151
|
+
file.close()
|
|
152
|
+
|
|
94
153
|
def _write_header(self):
|
|
95
154
|
"""Write header to the beginning of the file."""
|
|
96
|
-
dtype_bytes = self._dtype.encode("ascii")
|
|
97
|
-
dtype_bytes = dtype_bytes.ljust(8, b"\x00") # Pad with nulls
|
|
155
|
+
dtype_bytes = self._dtype.encode("ascii").ljust(self.MAX_DTYPE_LENGTH, b"\x00")
|
|
98
156
|
|
|
99
157
|
header = struct.pack(
|
|
100
158
|
self.HEADER_FORMAT,
|
|
@@ -126,6 +184,9 @@ class Array:
|
|
|
126
184
|
if version != self.HEADER_VERSION:
|
|
127
185
|
raise ValueError(f"Unsupported header version: {version}")
|
|
128
186
|
|
|
187
|
+
if dtype_len > self.MAX_DTYPE_LENGTH:
|
|
188
|
+
raise ValueError(f"File dtype length {dtype_len} exceeds header capacity")
|
|
189
|
+
|
|
129
190
|
# Extract dtype string
|
|
130
191
|
dtype = dtype_bytes[:dtype_len].decode("ascii")
|
|
131
192
|
|
|
@@ -153,6 +214,7 @@ class Array:
|
|
|
153
214
|
index = self._validate_index(index)
|
|
154
215
|
|
|
155
216
|
with self._lock:
|
|
217
|
+
self._ensure_writable()
|
|
156
218
|
if not self._mmap:
|
|
157
219
|
raise RuntimeError("Array is not memory-mapped. This should not happen if len > 0.")
|
|
158
220
|
|
|
@@ -162,6 +224,7 @@ class Array:
|
|
|
162
224
|
|
|
163
225
|
def append(self, value):
|
|
164
226
|
with self._lock:
|
|
227
|
+
self._ensure_writable()
|
|
165
228
|
if self._len == self._capacity:
|
|
166
229
|
self._resize(self._len + 1)
|
|
167
230
|
|
|
@@ -170,6 +233,7 @@ class Array:
|
|
|
170
233
|
|
|
171
234
|
self._mmap[offset : offset + self._element_size] = packed_value
|
|
172
235
|
self._len += 1
|
|
236
|
+
self._finalizer_state["length"] = self._len
|
|
173
237
|
|
|
174
238
|
def _allocate_capacity(self, min_elements):
|
|
175
239
|
"""Allocate capacity for at least min_elements, rounded up to chunk boundary."""
|
|
@@ -186,6 +250,7 @@ class Array:
|
|
|
186
250
|
|
|
187
251
|
self._allocate_capacity(min_new_len)
|
|
188
252
|
self._mmap = mmap.mmap(self._file.fileno(), 0)
|
|
253
|
+
self._finalizer_state["mmap"] = self._mmap
|
|
189
254
|
|
|
190
255
|
def extend(self, iterable):
|
|
191
256
|
values = list(iterable)
|
|
@@ -195,6 +260,7 @@ class Array:
|
|
|
195
260
|
return
|
|
196
261
|
|
|
197
262
|
with self._lock:
|
|
263
|
+
self._ensure_writable()
|
|
198
264
|
new_len = self._len + num_new_elements
|
|
199
265
|
if new_len > self._capacity:
|
|
200
266
|
self._resize(new_len)
|
|
@@ -207,6 +273,7 @@ class Array:
|
|
|
207
273
|
offset += self._element_size
|
|
208
274
|
|
|
209
275
|
self._len = new_len
|
|
276
|
+
self._finalizer_state["length"] = self._len
|
|
210
277
|
|
|
211
278
|
def __contains__(self, value):
|
|
212
279
|
for i in range(self._len):
|
|
@@ -225,15 +292,18 @@ class Array:
|
|
|
225
292
|
return NotImplemented
|
|
226
293
|
|
|
227
294
|
with self._lock:
|
|
295
|
+
self._ensure_writable()
|
|
228
296
|
if value == 0:
|
|
229
297
|
self._len = 0
|
|
230
298
|
if self._mmap:
|
|
231
299
|
self._mmap.close()
|
|
232
300
|
self._mmap = None
|
|
301
|
+
self._finalizer_state["mmap"] = None
|
|
233
302
|
if self._file:
|
|
234
303
|
self._file.truncate(0)
|
|
235
304
|
self._capacity = 0
|
|
236
305
|
self._capacity_bytes = 0
|
|
306
|
+
self._finalizer_state["length"] = self._len
|
|
237
307
|
elif value > 1:
|
|
238
308
|
original_len = self._len
|
|
239
309
|
new_total_len = original_len * value
|
|
@@ -253,31 +323,21 @@ class Array:
|
|
|
253
323
|
dst_offset += chunk_size
|
|
254
324
|
|
|
255
325
|
self._len = new_total_len
|
|
326
|
+
self._finalizer_state["length"] = self._len
|
|
256
327
|
return self
|
|
257
328
|
|
|
258
329
|
def flush(self):
|
|
259
|
-
|
|
260
|
-
self._mmap
|
|
330
|
+
with self._lock:
|
|
331
|
+
if self._mmap:
|
|
332
|
+
self._mmap.flush()
|
|
333
|
+
if self._file and self._writable:
|
|
334
|
+
self._write_header()
|
|
261
335
|
|
|
262
336
|
def close(self):
|
|
263
|
-
if self.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
self._mmap = None
|
|
268
|
-
|
|
269
|
-
if self._file:
|
|
270
|
-
# Update header with final length
|
|
271
|
-
self._write_header()
|
|
272
|
-
|
|
273
|
-
# Only truncate if the file was opened in a writable mode
|
|
274
|
-
# and if the current size is greater than the actual data length
|
|
275
|
-
current_file_size = os.fstat(self._file.fileno()).st_size
|
|
276
|
-
actual_total_size = self.HEADER_SIZE + self._len * self._element_size
|
|
277
|
-
if current_file_size > actual_total_size:
|
|
278
|
-
self._file.truncate(actual_total_size)
|
|
279
|
-
self._file.close()
|
|
280
|
-
self._file = None
|
|
337
|
+
if self._finalizer.alive:
|
|
338
|
+
self._finalizer()
|
|
339
|
+
self._mmap = None
|
|
340
|
+
self._file = None
|
|
281
341
|
|
|
282
342
|
def __enter__(self):
|
|
283
343
|
return self
|
|
File without changes
|
|
File without changes
|