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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrayfile
3
- Version: 0.0.1
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,7 +1,7 @@
1
1
  [project]
2
2
  name = "arrayfile"
3
3
  description = "Arrays backed by disk"
4
- version = "0.0.1"
4
+ version = "0.1.0"
5
5
  authors = [
6
6
  { name = "Gareth Davidson", email = "gaz@bitplane.net" }
7
7
  ]
@@ -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
- self._file = open(filename, "w+b")
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
- # 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)
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
- self._mmap = mmap.mmap(self._file.fileno(), 0)
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._finalizer = weakref.finalize(self, self.close)
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")[:8] # Limit to 8 bytes
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
- if self._mmap:
260
- self._mmap.flush()
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._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
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