arrayfile 0.0.1__tar.gz → 0.1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrayfile
3
- Version: 0.0.1
3
+ Version: 0.1.1
4
4
  Summary: Arrays backed by disk
5
5
  Keywords: logging,terminal,scrollback,indexing
6
6
  Author-email: Gareth Davidson <gaz@bitplane.net>
@@ -8,7 +8,6 @@ Requires-Python: >=3.10
8
8
  Description-Content-Type: text/markdown
9
9
  Classifier: Development Status :: 3 - Alpha
10
10
  Classifier: Intended Audience :: Developers
11
- Classifier: License :: OSI Approved :: MIT License
12
11
  Classifier: Programming Language :: Python :: 3
13
12
  Classifier: Programming Language :: Python :: 3.10
14
13
  Classifier: Programming Language :: Python :: 3.11
@@ -27,14 +26,14 @@ Requires-Dist: ruff ; extra == "dev"
27
26
  Requires-Dist: pydoc-markdown ; extra == "dev"
28
27
  Provides-Extra: dev
29
28
 
30
- # arrayfile
29
+ # 📃 arrayfile
31
30
 
32
31
  A file-backed numeric array using struct.pack. Does not support inserts or
33
32
  slicing.
34
33
 
35
34
  Smaller than relying on numpy though.
36
35
 
37
- ## Installation
36
+ ## ▶️ Installation
38
37
 
39
38
  ```bash
40
39
  pip install arrayfile
@@ -42,9 +41,10 @@ pip install arrayfile
42
41
 
43
42
  ## Usage
44
43
 
45
- ### Temporary Array
44
+ ### 🗑 Temporary Array
46
45
 
47
- This creates an array in your temp dir:
46
+ This creates an array in your temp dir. Its file is deleted when the array is
47
+ closed or garbage-collected. Arrays created with an explicit filename are kept:
48
48
 
49
49
  ```python
50
50
  from arrayfile import Array
@@ -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,9 +13,10 @@ pip install arrayfile
13
13
 
14
14
  ## Usage
15
15
 
16
- ### Temporary Array
16
+ ### 🗑 Temporary Array
17
17
 
18
- This creates an array in your temp dir:
18
+ This creates an array in your temp dir. Its file is deleted when the array is
19
+ closed or garbage-collected. Arrays created with an explicit filename are kept:
19
20
 
20
21
  ```python
21
22
  from arrayfile import Array
@@ -31,7 +32,7 @@ print(f"Values: {[arr[i] for i in range(len(arr))]}")
31
32
  arr.close() # Clean up resources
32
33
  ```
33
34
 
34
- ### Persistent Array
35
+ ### 💽 Persistent Array
35
36
 
36
37
  You can use the same file, if you want to persist your data across sessions:
37
38
 
@@ -55,7 +56,7 @@ arr.append(2000)
55
56
  arr.close()
56
57
  ```
57
58
 
58
- ## Context manager
59
+ ## 🔧 Context manager
59
60
 
60
61
  It has a finalizer in case you forget to call `close()`, but if you like to keep
61
62
  your code tidy, you can use a context manager, like so:
@@ -72,3 +73,18 @@ with Array('d', 'measurements.array', 'w+b') as arr:
72
73
  print(f" {i}: {value:.15f}")
73
74
  ```
74
75
 
76
+ ## ⚖️ License
77
+
78
+ WTFPL with one extra clause:
79
+
80
+ 1. Don't blame me.
81
+
82
+ Do what you like, but you're on your own.
83
+
84
+ ## 🔗 Links
85
+
86
+ * [🏠 home](https://bitplane.net/dev/python/arrayfile)
87
+ * [📖 pydoc](https://bitplane.net/dev/python/arrayfile/pydoc)
88
+ * [🐍 pypi](https://pypi.org/project/arrayfile)
89
+ * [🐱 github](https://github.com/bitplane/arrayfile)
90
+
@@ -1,18 +1,17 @@
1
1
  [project]
2
2
  name = "arrayfile"
3
3
  description = "Arrays backed by disk"
4
- version = "0.0.1"
4
+ version = "0.1.1"
5
5
  authors = [
6
6
  { name = "Gareth Davidson", email = "gaz@bitplane.net" }
7
7
  ]
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.10"
10
- license = {text = "CC0"} # wtfpl
10
+ license = {file = "LICENSE.md"}
11
11
  keywords = ["logging", "terminal", "scrollback", "indexing"]
12
12
  classifiers = [
13
13
  "Development Status :: 3 - Alpha",
14
14
  "Intended Audience :: Developers",
15
- "License :: OSI Approved :: MIT License",
16
15
  "Programming Language :: Python :: 3",
17
16
  "Programming Language :: Python :: 3.10",
18
17
  "Programming Language :: Python :: 3.11",
@@ -1,5 +1,7 @@
1
+ import io
1
2
  import mmap
2
3
  import os
4
+ import re
3
5
  import struct
4
6
  import tempfile
5
7
  import threading
@@ -8,6 +10,7 @@ import weakref
8
10
 
9
11
  class Array:
10
12
  CHUNK_SIZE_BYTES = 4096
13
+ MAX_DTYPE_LENGTH = 8
11
14
 
12
15
  # Header constants
13
16
  MAGIC = b"ARYF"
@@ -19,8 +22,36 @@ class Array:
19
22
 
20
23
  def __init__(self, dtype, filename=None, mode="r+b", initial_elements=0):
21
24
  self._lock = threading.Lock()
25
+ if isinstance(mode, str) and "a" in mode:
26
+ raise ValueError("Append mode is not supported; use r+b and append()")
27
+ if mode not in ("rb", "r+b", "rb+", "wb", "w+b", "wb+", "xb", "x+b", "xb+"):
28
+ raise ValueError("mode must be a binary read, write, or exclusive-create mode")
29
+ if filename is None and mode == "rb":
30
+ raise ValueError("Read-only mode requires a filename")
22
31
 
23
- if filename is None:
32
+ try:
33
+ dtype_bytes = dtype.encode("ascii")
34
+ except (AttributeError, UnicodeEncodeError) as error:
35
+ raise ValueError("dtype must be an ASCII struct format string") from error
36
+ if len(dtype_bytes) > self.MAX_DTYPE_LENGTH:
37
+ raise ValueError(f"dtype must be at most {self.MAX_DTYPE_LENGTH} ASCII bytes")
38
+
39
+ try:
40
+ element_size = struct.calcsize(dtype)
41
+ except struct.error as error:
42
+ raise ValueError("dtype must be a valid struct format string") from error
43
+ value_count = 0
44
+ for count, code in re.findall(r"(\d*)([A-Za-z?])", dtype):
45
+ repeat = int(count) if count else 1
46
+ if code == "p" and repeat == 0:
47
+ raise ValueError("dtype cannot contain a zero-length Pascal string")
48
+ if code != "x":
49
+ value_count += 1 if code in "sp" else repeat
50
+ if element_size == 0 or value_count != 1:
51
+ raise ValueError("dtype must have positive size and contain exactly one value")
52
+
53
+ temporary = filename is None
54
+ if temporary:
24
55
  fd, filename = tempfile.mkstemp()
25
56
  os.close(fd)
26
57
  mode = "w+b" # Always create new temp files
@@ -28,7 +59,7 @@ class Array:
28
59
  self._filename = filename
29
60
  self._dtype = dtype
30
61
  self._dtype_format = dtype
31
- self._element_size = struct.calcsize(dtype)
62
+ self._element_size = element_size
32
63
  self._file = None
33
64
  self._mmap = None
34
65
  self._len = 0
@@ -36,31 +67,63 @@ class Array:
36
67
  self._capacity_bytes = 0 # Initialize _capacity_bytes here
37
68
  self._data_offset = self.HEADER_SIZE # All data starts after header
38
69
 
39
- if "w" in mode or not os.path.exists(filename):
40
- # Create or truncate file
41
- self._file = open(filename, "w+b")
42
- self._len = 0
43
- self._allocate_capacity(initial_elements)
44
- self._write_header()
45
- else:
46
- # Open existing file
47
- self._file = open(filename, mode)
48
- if not self._read_header():
49
- raise ValueError("File does not have a valid array header")
50
-
51
- current_file_size = os.fstat(self._file.fileno()).st_size
52
- data_size = current_file_size - self.HEADER_SIZE
53
-
54
- # Calculate capacity based on current data size and ensure chunk alignment
55
- min_elements = (data_size + self._element_size - 1) // self._element_size
56
- self._allocate_capacity(min_elements)
57
-
58
- # Only mmap if the file has a non-zero size
59
- if self._capacity_bytes > 0:
60
- self._mmap = mmap.mmap(self._file.fileno(), 0)
70
+ try:
71
+ if "w" in mode or "x" in mode:
72
+ # Mapping and header updates require random-access reads and writes.
73
+ mode = "x+b" if "x" in mode else "w+b"
74
+ self._file = open(filename, mode) # noqa: SIM115 - kept open for the Array lifetime
75
+ self._len = 0
76
+ self._allocate_capacity(initial_elements)
77
+ self._write_header()
78
+ else:
79
+ # Open existing file
80
+ self._file = open(filename, mode) # noqa: SIM115 - kept open for the Array lifetime
81
+ if not self._read_header():
82
+ raise ValueError("File does not have a valid array header")
83
+
84
+ current_file_size = os.fstat(self._file.fileno()).st_size
85
+ data_size = current_file_size - self.HEADER_SIZE
86
+ required_data_size = self._len * self._element_size
87
+ if required_data_size > data_size:
88
+ raise ValueError(
89
+ f"Array header declares {self._len} elements, but file only contains "
90
+ f"{data_size // self._element_size}"
91
+ )
92
+
93
+ self._capacity_bytes = data_size
94
+ self._capacity = data_size // self._element_size
95
+
96
+ self._writable = any(flag in mode for flag in "+wa")
97
+
98
+ # Only mmap if the file has a non-zero size
99
+ if self._capacity_bytes > 0:
100
+ access = mmap.ACCESS_WRITE if self._writable else mmap.ACCESS_READ
101
+ self._mmap = mmap.mmap(self._file.fileno(), 0, access=access)
102
+
103
+ except BaseException:
104
+ try:
105
+ if self._mmap is not None:
106
+ self._mmap.close()
107
+ finally:
108
+ try:
109
+ if self._file is not None:
110
+ self._file.close()
111
+ finally:
112
+ if temporary:
113
+ os.unlink(filename)
114
+ raise
61
115
 
62
116
  # Set up finalizer to ensure cleanup even if close() isn't called
63
- self._finalizer = weakref.finalize(self, self.close)
117
+ self._finalizer_state = {
118
+ "dtype": self._dtype,
119
+ "element_size": self._element_size,
120
+ "file": self._file,
121
+ "length": self._len,
122
+ "mmap": self._mmap,
123
+ "writable": self._writable,
124
+ "temporary_path": filename if temporary else None,
125
+ }
126
+ self._finalizer = weakref.finalize(self, self._finalize_resources, self._finalizer_state)
64
127
 
65
128
  def __len__(self):
66
129
  return self._len
@@ -91,10 +154,52 @@ class Array:
91
154
  except struct.error as e:
92
155
  raise TypeError(f"Value {value} cannot be packed as {self._dtype_format}: {e}")
93
156
 
157
+ def _ensure_writable(self):
158
+ if not self._writable:
159
+ raise io.UnsupportedOperation("Array is not writable")
160
+
161
+ @classmethod
162
+ def _finalize_resources(cls, state):
163
+ mapped_file = state["mmap"]
164
+ file = state["file"]
165
+ try:
166
+ if mapped_file is not None:
167
+ try:
168
+ mapped_file.flush()
169
+ finally:
170
+ mapped_file.close()
171
+ if file is None:
172
+ return
173
+
174
+ if state["writable"]:
175
+ dtype_bytes = state["dtype"].encode("ascii").ljust(cls.MAX_DTYPE_LENGTH, b"\x00")
176
+ header = struct.pack(
177
+ cls.HEADER_FORMAT,
178
+ cls.MAGIC,
179
+ cls.HEADER_VERSION,
180
+ len(state["dtype"]),
181
+ dtype_bytes,
182
+ state["element_size"],
183
+ state["length"],
184
+ )
185
+ file.seek(0)
186
+ file.write(header)
187
+ file.truncate(cls.HEADER_SIZE + state["length"] * state["element_size"])
188
+ file.flush()
189
+ finally:
190
+ try:
191
+ if file is not None:
192
+ file.close()
193
+ finally:
194
+ if state["temporary_path"] is not None:
195
+ try:
196
+ os.unlink(state["temporary_path"])
197
+ except FileNotFoundError:
198
+ pass
199
+
94
200
  def _write_header(self):
95
201
  """Write header to the beginning of the file."""
96
- dtype_bytes = self._dtype.encode("ascii")[:8] # Limit to 8 bytes
97
- dtype_bytes = dtype_bytes.ljust(8, b"\x00") # Pad with nulls
202
+ dtype_bytes = self._dtype.encode("ascii").ljust(self.MAX_DTYPE_LENGTH, b"\x00")
98
203
 
99
204
  header = struct.pack(
100
205
  self.HEADER_FORMAT,
@@ -126,6 +231,9 @@ class Array:
126
231
  if version != self.HEADER_VERSION:
127
232
  raise ValueError(f"Unsupported header version: {version}")
128
233
 
234
+ if dtype_len > self.MAX_DTYPE_LENGTH:
235
+ raise ValueError(f"File dtype length {dtype_len} exceeds header capacity")
236
+
129
237
  # Extract dtype string
130
238
  dtype = dtype_bytes[:dtype_len].decode("ascii")
131
239
 
@@ -153,6 +261,7 @@ class Array:
153
261
  index = self._validate_index(index)
154
262
 
155
263
  with self._lock:
264
+ self._ensure_writable()
156
265
  if not self._mmap:
157
266
  raise RuntimeError("Array is not memory-mapped. This should not happen if len > 0.")
158
267
 
@@ -162,6 +271,7 @@ class Array:
162
271
 
163
272
  def append(self, value):
164
273
  with self._lock:
274
+ self._ensure_writable()
165
275
  if self._len == self._capacity:
166
276
  self._resize(self._len + 1)
167
277
 
@@ -170,22 +280,41 @@ class Array:
170
280
 
171
281
  self._mmap[offset : offset + self._element_size] = packed_value
172
282
  self._len += 1
283
+ self._finalizer_state["length"] = self._len
173
284
 
174
285
  def _allocate_capacity(self, min_elements):
175
286
  """Allocate capacity for at least min_elements, rounded up to chunk boundary."""
176
287
  bytes_needed = min_elements * self._element_size + self.HEADER_SIZE
177
288
  chunks_needed = (bytes_needed + self.CHUNK_SIZE_BYTES - 1) // self.CHUNK_SIZE_BYTES
178
289
  total_file_size = chunks_needed * self.CHUNK_SIZE_BYTES
290
+ self._file.truncate(total_file_size)
179
291
  self._capacity_bytes = total_file_size - self.HEADER_SIZE
180
292
  self._capacity = self._capacity_bytes // self._element_size
181
- self._file.truncate(total_file_size)
182
293
 
183
294
  def _resize(self, min_new_len):
184
- if self._mmap:
295
+ old_capacity = self._capacity
296
+ old_capacity_bytes = self._capacity_bytes
297
+ if self._mmap is not None:
185
298
  self._mmap.close()
299
+ self._mmap = None
300
+ self._finalizer_state["mmap"] = None
186
301
 
187
- self._allocate_capacity(min_new_len)
188
- self._mmap = mmap.mmap(self._file.fileno(), 0)
302
+ try:
303
+ self._allocate_capacity(min_new_len)
304
+ self._mmap = mmap.mmap(self._file.fileno(), 0)
305
+ except Exception:
306
+ self._capacity = old_capacity
307
+ self._capacity_bytes = old_capacity_bytes
308
+ # Restore access to existing values if possible. Cleanup must remain
309
+ # usable even when the operating system cannot create any mapping.
310
+ if old_capacity_bytes:
311
+ try:
312
+ self._mmap = mmap.mmap(self._file.fileno(), 0)
313
+ except OSError:
314
+ pass
315
+ raise
316
+ finally:
317
+ self._finalizer_state["mmap"] = self._mmap
189
318
 
190
319
  def extend(self, iterable):
191
320
  values = list(iterable)
@@ -195,6 +324,7 @@ class Array:
195
324
  return
196
325
 
197
326
  with self._lock:
327
+ self._ensure_writable()
198
328
  new_len = self._len + num_new_elements
199
329
  if new_len > self._capacity:
200
330
  self._resize(new_len)
@@ -207,6 +337,7 @@ class Array:
207
337
  offset += self._element_size
208
338
 
209
339
  self._len = new_len
340
+ self._finalizer_state["length"] = self._len
210
341
 
211
342
  def __contains__(self, value):
212
343
  for i in range(self._len):
@@ -225,17 +356,22 @@ class Array:
225
356
  return NotImplemented
226
357
 
227
358
  with self._lock:
359
+ self._ensure_writable()
228
360
  if value == 0:
229
361
  self._len = 0
230
362
  if self._mmap:
231
363
  self._mmap.close()
232
364
  self._mmap = None
365
+ self._finalizer_state["mmap"] = None
233
366
  if self._file:
234
367
  self._file.truncate(0)
235
368
  self._capacity = 0
236
369
  self._capacity_bytes = 0
370
+ self._finalizer_state["length"] = self._len
237
371
  elif value > 1:
238
372
  original_len = self._len
373
+ if original_len == 0:
374
+ return self
239
375
  new_total_len = original_len * value
240
376
 
241
377
  # Resize if needed
@@ -253,31 +389,24 @@ class Array:
253
389
  dst_offset += chunk_size
254
390
 
255
391
  self._len = new_total_len
392
+ self._finalizer_state["length"] = self._len
256
393
  return self
257
394
 
258
395
  def flush(self):
259
- if self._mmap:
260
- self._mmap.flush()
396
+ with self._lock:
397
+ if self._mmap:
398
+ self._mmap.flush()
399
+ if self._file and self._writable:
400
+ self._write_header()
261
401
 
262
402
  def close(self):
263
- if self._mmap:
264
- # Ensure all writes are on disk before truncating
265
- self._mmap.flush()
266
- self._mmap.close()
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
403
+ with self._lock:
404
+ try:
405
+ if self._finalizer.alive:
406
+ self._finalizer()
407
+ finally:
408
+ self._mmap = None
409
+ self._file = None
281
410
 
282
411
  def __enter__(self):
283
412
  return self
File without changes