arrayfile 0.1.0__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.1.0
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
@@ -44,7 +43,8 @@ pip install arrayfile
44
43
 
45
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
@@ -15,7 +15,8 @@ pip install arrayfile
15
15
 
16
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
@@ -1,18 +1,17 @@
1
1
  [project]
2
2
  name = "arrayfile"
3
3
  description = "Arrays backed by disk"
4
- version = "0.1.0"
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,6 +1,7 @@
1
1
  import io
2
2
  import mmap
3
3
  import os
4
+ import re
4
5
  import struct
5
6
  import tempfile
6
7
  import threading
@@ -21,6 +22,12 @@ class Array:
21
22
 
22
23
  def __init__(self, dtype, filename=None, mode="r+b", initial_elements=0):
23
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")
24
31
 
25
32
  try:
26
33
  dtype_bytes = dtype.encode("ascii")
@@ -29,7 +36,22 @@ class Array:
29
36
  if len(dtype_bytes) > self.MAX_DTYPE_LENGTH:
30
37
  raise ValueError(f"dtype must be at most {self.MAX_DTYPE_LENGTH} ASCII bytes")
31
38
 
32
- if filename is None:
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:
33
55
  fd, filename = tempfile.mkstemp()
34
56
  os.close(fd)
35
57
  mode = "w+b" # Always create new temp files
@@ -37,7 +59,7 @@ class Array:
37
59
  self._filename = filename
38
60
  self._dtype = dtype
39
61
  self._dtype_format = dtype
40
- self._element_size = struct.calcsize(dtype)
62
+ self._element_size = element_size
41
63
  self._file = None
42
64
  self._mmap = None
43
65
  self._len = 0
@@ -45,37 +67,51 @@ class Array:
45
67
  self._capacity_bytes = 0 # Initialize _capacity_bytes here
46
68
  self._data_offset = self.HEADER_SIZE # All data starts after header
47
69
 
48
- if "w" in mode or not os.path.exists(filename):
49
- # Create or truncate file
50
- mode = "w+b"
51
- self._file = open(filename, "w+b") # noqa: SIM115 - kept open for the Array lifetime
52
- self._len = 0
53
- self._allocate_capacity(initial_elements)
54
- self._write_header()
55
- else:
56
- # Open existing file
57
- self._file = open(filename, mode) # noqa: SIM115 - kept open for the Array lifetime
58
- if not self._read_header():
59
- raise ValueError("File does not have a valid array header")
60
-
61
- current_file_size = os.fstat(self._file.fileno()).st_size
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
72
-
73
- self._writable = any(flag in mode for flag in "+wa")
74
-
75
- # Only mmap if the file has a non-zero size
76
- if self._capacity_bytes > 0:
77
- access = mmap.ACCESS_WRITE if self._writable else mmap.ACCESS_READ
78
- self._mmap = mmap.mmap(self._file.fileno(), 0, access=access)
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
79
115
 
80
116
  # Set up finalizer to ensure cleanup even if close() isn't called
81
117
  self._finalizer_state = {
@@ -85,6 +121,7 @@ class Array:
85
121
  "length": self._len,
86
122
  "mmap": self._mmap,
87
123
  "writable": self._writable,
124
+ "temporary_path": filename if temporary else None,
88
125
  }
89
126
  self._finalizer = weakref.finalize(self, self._finalize_resources, self._finalizer_state)
90
127
 
@@ -124,31 +161,41 @@ class Array:
124
161
  @classmethod
125
162
  def _finalize_resources(cls, state):
126
163
  mapped_file = state["mmap"]
127
- if mapped_file:
128
- mapped_file.flush()
129
- mapped_file.close()
130
-
131
164
  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()
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
152
199
 
153
200
  def _write_header(self):
154
201
  """Write header to the beginning of the file."""
@@ -240,17 +287,34 @@ class Array:
240
287
  bytes_needed = min_elements * self._element_size + self.HEADER_SIZE
241
288
  chunks_needed = (bytes_needed + self.CHUNK_SIZE_BYTES - 1) // self.CHUNK_SIZE_BYTES
242
289
  total_file_size = chunks_needed * self.CHUNK_SIZE_BYTES
290
+ self._file.truncate(total_file_size)
243
291
  self._capacity_bytes = total_file_size - self.HEADER_SIZE
244
292
  self._capacity = self._capacity_bytes // self._element_size
245
- self._file.truncate(total_file_size)
246
293
 
247
294
  def _resize(self, min_new_len):
248
- if self._mmap:
295
+ old_capacity = self._capacity
296
+ old_capacity_bytes = self._capacity_bytes
297
+ if self._mmap is not None:
249
298
  self._mmap.close()
299
+ self._mmap = None
300
+ self._finalizer_state["mmap"] = None
250
301
 
251
- self._allocate_capacity(min_new_len)
252
- self._mmap = mmap.mmap(self._file.fileno(), 0)
253
- self._finalizer_state["mmap"] = self._mmap
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
254
318
 
255
319
  def extend(self, iterable):
256
320
  values = list(iterable)
@@ -306,6 +370,8 @@ class Array:
306
370
  self._finalizer_state["length"] = self._len
307
371
  elif value > 1:
308
372
  original_len = self._len
373
+ if original_len == 0:
374
+ return self
309
375
  new_total_len = original_len * value
310
376
 
311
377
  # Resize if needed
@@ -334,10 +400,13 @@ class Array:
334
400
  self._write_header()
335
401
 
336
402
  def close(self):
337
- if self._finalizer.alive:
338
- self._finalizer()
339
- self._mmap = None
340
- 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
341
410
 
342
411
  def __enter__(self):
343
412
  return self
File without changes