Skip to content

Commit 937d592

Browse files
committed
Added LockedFD class including test, it moved 'down' from git-python
1 parent 133988a commit 937d592

2 files changed

Lines changed: 221 additions & 1 deletion

File tree

test/test_util.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
"""Test for object db"""
2+
import tempfile
3+
import os
4+
25
from lib import TestBase
36
from gitdb.util import (
47
to_hex_sha,
58
to_bin_sha,
6-
NULL_HEX_SHA
9+
NULL_HEX_SHA,
10+
LockedFD
711
)
812

913

@@ -12,4 +16,76 @@ def test_basics(self):
1216
assert to_hex_sha(NULL_HEX_SHA) == NULL_HEX_SHA
1317
assert len(to_bin_sha(NULL_HEX_SHA)) == 20
1418
assert to_hex_sha(to_bin_sha(NULL_HEX_SHA)) == NULL_HEX_SHA
19+
20+
def _cmp_contents(self, file_path, data):
21+
# raise if data from file at file_path
22+
# does not match data string
23+
fp = open(file_path, "rb")
24+
try:
25+
assert fp.read() == data
26+
finally:
27+
fp.close()
28+
29+
def test_lockedfd(self):
30+
my_file = tempfile.mktemp()
31+
orig_data = "hello"
32+
new_data = "world"
33+
my_file_fp = open(my_file, "wb")
34+
my_file_fp.write(orig_data)
35+
my_file_fp.close()
36+
37+
try:
38+
lfd = LockedFD(my_file)
39+
lockfilepath = lfd._lockfilepath()
40+
41+
# cannot end before it was started
42+
self.failUnlessRaises(AssertionError, lfd.rollback)
43+
self.failUnlessRaises(AssertionError, lfd.commit)
44+
45+
# open for writing
46+
assert not os.path.isfile(lockfilepath)
47+
wfd = lfd.open(write=True)
48+
assert lfd._fd is wfd
49+
assert os.path.isfile(lockfilepath)
50+
51+
# write data and fail
52+
os.write(wfd, new_data)
53+
lfd.rollback()
54+
assert lfd._fd is None
55+
self._cmp_contents(my_file, orig_data)
56+
assert not os.path.isfile(lockfilepath)
57+
58+
# additional call doesnt fail
59+
lfd.commit()
60+
lfd.rollback()
61+
62+
# test reading
63+
lfd = LockedFD(my_file)
64+
rfd = lfd.open(write=False)
65+
assert os.read(rfd, len(orig_data)) == orig_data
66+
67+
assert os.path.isfile(lockfilepath)
68+
# deletion rolls back
69+
del(lfd)
70+
assert not os.path.isfile(lockfilepath)
71+
72+
73+
# write data - concurrently
74+
lfd = LockedFD(my_file)
75+
olfd = LockedFD(my_file)
76+
assert not os.path.isfile(lockfilepath)
77+
wfdstream = lfd.open(write=True, stream=True) # this time as stream
78+
assert os.path.isfile(lockfilepath)
79+
# another one fails
80+
self.failUnlessRaises(IOError, olfd.open)
81+
82+
wfdstream.write(new_data)
83+
lfd.commit()
84+
assert not os.path.isfile(lockfilepath)
85+
self._cmp_contents(my_file, new_data)
86+
87+
# could test automatic _end_writing on destruction
88+
finally:
89+
os.remove(my_file)
90+
# END final cleanup
1591

util.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import binascii
22
import os
3+
import sys
34
import errno
45

56
try:
@@ -89,3 +90,146 @@ def to_bin_sha(sha):
8990

9091
#} END routines
9192

93+
94+
#{ Utilities
95+
96+
97+
class FDStreamWrapper(object):
98+
"""A simple wrapper providing the most basic functions on a file descriptor
99+
with the fileobject interface. Cannot use os.fdopen as the resulting stream
100+
takes ownership"""
101+
__slots__ = ("_fd", '_pos')
102+
def __init__(self, fd):
103+
self._fd = fd
104+
self._pos = 0
105+
106+
def write(self, data):
107+
self._pos += len(data)
108+
os.write(self._fd, data)
109+
110+
def read(self, count=0):
111+
if count == 0:
112+
count = os.path.getsize(self._filepath)
113+
# END handle read everything
114+
115+
bytes = os.read(self._fd, count)
116+
self._pos += len(bytes)
117+
return bytes
118+
119+
def fileno(self):
120+
return self._fd
121+
122+
def tell(self):
123+
return self._pos
124+
125+
126+
class LockedFD(object):
127+
"""This class facilitates a safe read and write operation to a file on disk.
128+
If we write to 'file', we obtain a lock file at 'file.lock' and write to
129+
that instead. If we succeed, the lock file will be renamed to overwrite
130+
the original file.
131+
132+
When reading, we obtain a lock file, but to prevent other writers from
133+
succeeding while we are reading the file.
134+
135+
This type handles error correctly in that it will assure a consistent state
136+
on destruction.
137+
138+
:note: with this setup, parallel reading is not possible"""
139+
__slots__ = ("_filepath", '_fd', '_write')
140+
141+
def __init__(self, filepath):
142+
"""Initialize an instance with the givne filepath"""
143+
self._filepath = filepath
144+
self._fd = None
145+
self._write = None # if True, we write a file
146+
147+
def __del__(self):
148+
# will do nothing if the file descriptor is already closed
149+
if self._fd is not None:
150+
self.rollback()
151+
152+
def _lockfilepath(self):
153+
return "%s.lock" % self._filepath
154+
155+
def open(self, write=False, stream=False):
156+
"""Open the file descriptor for reading or writing, both in binary mode.
157+
:param write: if True, the file descriptor will be opened for writing. Other
158+
wise it will be opened read-only.
159+
:param stream: if True, the file descriptor will be wrapped into a simple stream
160+
object which supports only reading or writing
161+
:return: fd to read from or write to. It is still maintained by this instance
162+
and must not be closed directly
163+
:raise IOError: if the lock could not be retrieved
164+
:raise OSError: If the actual file could not be opened for reading
165+
:note: must only be called once"""
166+
if self._write is not None:
167+
raise AssertionError("Called %s multiple times" % self.open)
168+
169+
self._write = write
170+
171+
# try to open the lock file
172+
binary = getattr(os, 'O_BINARY', 0)
173+
lockmode = os.O_WRONLY | os.O_CREAT | os.O_EXCL | binary
174+
try:
175+
fd = os.open(self._lockfilepath(), lockmode)
176+
if not write:
177+
os.close(fd)
178+
else:
179+
self._fd = fd
180+
# END handle file descriptor
181+
except OSError:
182+
raise IOError("Lock at %r could not be obtained" % self._lockfilepath())
183+
# END handle lock retrieval
184+
185+
# open actual file if required
186+
if self._fd is None:
187+
# we could specify exlusive here, as we obtained the lock anyway
188+
self._fd = os.open(self._filepath, os.O_RDONLY | binary)
189+
# END open descriptor for reading
190+
191+
if stream:
192+
return FDStreamWrapper(self._fd)
193+
else:
194+
return self._fd
195+
# END handle stream
196+
197+
def commit(self):
198+
"""When done writing, call this function to commit your changes into the
199+
actual file.
200+
The file descriptor will be closed, and the lockfile handled.
201+
:note: can be called multiple times"""
202+
self._end_writing(successful=True)
203+
204+
def rollback(self):
205+
"""Abort your operation without any changes. The file descriptor will be
206+
closed, and the lock released.
207+
:note: can be called multiple times"""
208+
self._end_writing(successful=False)
209+
210+
def _end_writing(self, successful=True):
211+
"""Handle the lock according to the write mode """
212+
if self._write is None:
213+
raise AssertionError("Cannot end operation if it wasn't started yet")
214+
215+
if self._fd is None:
216+
return
217+
218+
os.close(self._fd)
219+
self._fd = None
220+
221+
lockfile = self._lockfilepath()
222+
if self._write and successful:
223+
# on windows, rename does not silently overwrite the existing one
224+
if sys.platform == "win32":
225+
if os.path.isfile(self._filepath):
226+
os.remove(self._filepath)
227+
# END remove if exists
228+
# END win32 special handling
229+
os.rename(lockfile, self._filepath)
230+
else:
231+
# just delete the file so far, we failed
232+
os.remove(lockfile)
233+
# END successful handling
234+
235+
#} END utilities

0 commit comments

Comments
 (0)