sgfa 0.1.0

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.
@@ -0,0 +1,141 @@
1
+ #
2
+ # Simple Group of Filing Applications
3
+ # Locking using filesystem locks
4
+ #
5
+ # Copyright (C) 2015 by Graham A. Field.
6
+ #
7
+ # See LICENSE.txt for licensing information.
8
+ #
9
+ # This program is distributed WITHOUT ANY WARRANTY; without even the
10
+ # implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11
+
12
+ require_relative 'error'
13
+
14
+ module Sgfa
15
+
16
+
17
+ ##########################################################################
18
+ # Lock based on file locks
19
+ class LockFs
20
+
21
+
22
+ ################################
23
+ # Lock state
24
+ #
25
+ # @return [Symbol] :closed, :unlocked, :shared, or :exclusive
26
+ attr_reader :state
27
+
28
+
29
+ ################################
30
+ # Initialize
31
+ def initialize
32
+ @file = nil
33
+ @state = :closed
34
+ end # def initialize
35
+
36
+
37
+ ################################
38
+ # Open lock file
39
+ #
40
+ # @todo Handle exceptions from File.open()
41
+ #
42
+ # @param fnam [String] File name of lock file
43
+ # @return [String] Contents of the lock file
44
+ # @raise [Error::Sanity] if lock already open
45
+ def open(fnam)
46
+ raise Error::Sanity, 'Lock file already open' if @file
47
+ @file = File.open(fnam, 'r', :encoding => 'utf-8')
48
+ @state = :unlocked
49
+ return @file.read
50
+ end # def open()
51
+
52
+
53
+ ################################
54
+ # Close lock file
55
+ #
56
+ # @raise [Error::Sanity] if lock not open
57
+ def close
58
+ raise Error::Sanity, 'Lock file not open' if !@file
59
+ @file.close
60
+ @file = nil
61
+ @state = :closed
62
+ end # def close
63
+
64
+
65
+ ################################
66
+ # Take exclusive lock
67
+ #
68
+ # @raise [Error::Sanity] if lock not open
69
+ def exclusive
70
+ raise Error::Sanity, 'Lock file not open' if !@file
71
+
72
+ if @state == :exclusive
73
+ return
74
+ elsif @state == :shared
75
+ @file.flock(File::LOCK_UN)
76
+ end
77
+
78
+ @file.flock(File::LOCK_EX)
79
+ @state = :exclusive
80
+ end # def exclusive
81
+
82
+
83
+ ################################
84
+ # Take shared lock
85
+ #
86
+ # @raise [Error::Sanity] if lock not open
87
+ def shared
88
+ raise Error::Sanity, 'Lock file not open' if !@file
89
+ return if @state == :shared
90
+ @file.flock(File::LOCK_SH)
91
+ @state = :shared
92
+ end # def shared
93
+
94
+
95
+ ################################
96
+ # Release lock
97
+ #
98
+ # @raise [Error::Sanity] if lock not open
99
+ def unlock
100
+ raise Error::Sanity, 'Lock file not open' if !@file
101
+ return if @state == :unlocked
102
+ @file.flock(File::LOCK_UN)
103
+ @state = :unlocked
104
+ end # def unlock
105
+
106
+
107
+ ################################
108
+ # Run block while holding exclusive lock
109
+ #
110
+ # @param rel [Boolean] Release lock on return
111
+ # @raise (see #exclusive)
112
+ def do_ex(rel=true)
113
+ exclusive
114
+ begin
115
+ ret = yield
116
+ ensure
117
+ unlock if rel
118
+ end
119
+ return ret
120
+ end # def do_ex
121
+
122
+
123
+ ################################
124
+ # Run block while holding shared lock
125
+ #
126
+ # @param rel [Boolean] Release lock on return
127
+ # @riase (see #shared)
128
+ def do_sh(rel=true)
129
+ shared
130
+ begin
131
+ ret = yield
132
+ ensure
133
+ unlock if rel
134
+ end
135
+ return ret
136
+ end # def do_sh
137
+
138
+
139
+ end # class LockFs
140
+
141
+ end # module Sgfa
@@ -0,0 +1,342 @@
1
+ #
2
+ # Simple Group of Filing Applications
3
+ # Jacket state using filesystem storage.
4
+ #
5
+ # Copyright (C) 2015 by Graham A. Field.
6
+ #
7
+ # See LICENSE.txt for licensing information.
8
+ #
9
+ # This program is distributed WITHOUT ANY WARRANTY; without even the
10
+ # implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11
+
12
+ require 'fileutils'
13
+ require 'tempfile'
14
+
15
+ require_relative 'error'
16
+
17
+ module Sgfa
18
+
19
+
20
+ #####################################################################
21
+ # Maintains {Jacket} state using filesystem storage.
22
+ #
23
+ # This stores the current {History} number for a {Jacket} as well as
24
+ # the current revision numbers for each {Entry}. It also maintains
25
+ # for each tag, a date/time ordered list of all {Entry} items where
26
+ # the current revision contains that tag. Finally, it keeps a list of
27
+ # all tags which are present in the {Jacket}.
28
+ #
29
+ # The current revision of each entry is stored in a file named
30
+ # "_state" with each line consisting of a 9-digit current
31
+ # revision (zero padded) followed by newline. The current {History}
32
+ # number is stored on line zero, with each {Entry} on the corresponding
33
+ # line.
34
+ #
35
+ # The list of tags is in a file named "_list" and consists of
36
+ # each tag followed by a newline.
37
+ #
38
+ # Each tag is kept in a seperate file, named with the tag name. Each
39
+ # line consists of a UTC date/time in "YYYY-MM-DD HH:MM:SS" format,
40
+ # followed by a space, and the 9-digit (zero padded) {Entry} number,
41
+ # with a newline. A "_all" tag tracks all {Entry}s.
42
+ #
43
+ class StateFs
44
+
45
+
46
+ # Tag which has all {Entry} items in a {Jacket}
47
+ TagAll = '_all'
48
+
49
+ # File name to store list of all tags
50
+ TagList = '_list'
51
+
52
+ # File name used to store the current {History} and {Entry} revision
53
+ # numbers
54
+ TagState = '_state'
55
+
56
+ # Size (bytes) of each entry revision
57
+ EntrySize = 10
58
+
59
+ # Size (bytes) of each tag date/time and entry listing
60
+ TagSize = 30
61
+
62
+ # Regular expression to get date/time string and entry number
63
+ # from a tag file
64
+ TagReg = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (\d{9})$/
65
+
66
+ private_constant :TagAll, :TagList, :TagState, :EntrySize, :TagSize,
67
+ :TagReg
68
+
69
+
70
+ #####################################
71
+ # Create a new state
72
+ #
73
+ # @param path [String] Path to the directory containing the state
74
+ # @raise [Error::Conflict] if path already exists
75
+ def self.create(path)
76
+ begin
77
+ Dir.mkdir(path)
78
+ rescue Errno::EEXIST
79
+ raise Error::Conflict, 'State path already exists'
80
+ end
81
+ FileUtils.touch(File.join(path, TagList))
82
+ File.open(File.join(path, TagState), 'wb'){|fi| fi.write "000000000\n"}
83
+ end # def self.create()
84
+
85
+
86
+ #####################################
87
+ # Initialize new state, optionally opening
88
+ #
89
+ # @param path [String] Path to the directory containing the state
90
+ # @raise (see #open)
91
+ def initialize(path=nil)
92
+ open(path) if path
93
+ end # def initialize()
94
+
95
+
96
+ #####################################
97
+ # Open the state
98
+ #
99
+ # @param path [String] Path to the directory containing the state
100
+ # @raise [Error::NonExistent] if path does not exist
101
+ # @return [StateFs] self
102
+ def open(path)
103
+ fn = File.join(path, TagState)
104
+ begin
105
+ @file = File.open(fn, 'r+b')
106
+ rescue Errno::ENOENT
107
+ raise Error::NonExistent, 'State path does not exist'
108
+ end
109
+ @path = path.dup
110
+ return self
111
+ end # def open()
112
+
113
+
114
+ #####################################
115
+ # Close the state
116
+ #
117
+ # @raise [Error::Sanity] if state is not open
118
+ # @return [StateFs] self
119
+ def close
120
+ raise Error::Sanity, 'State not open' if !@path
121
+ @file.close
122
+ @path = nil
123
+ return self
124
+ end # def close
125
+
126
+
127
+ #####################################
128
+ # Reset to empty state
129
+ #
130
+ # @raise [Error::Sanity] if state is not open
131
+ # @return (see #open)
132
+ def reset
133
+ raise Error::Sanity, 'State not open' if !@path
134
+
135
+ path = @path
136
+ self.close
137
+
138
+ FileUtils.remove_dir(path)
139
+ Dir.mkdir(path)
140
+ FileUtils.touch(File.join(path, TagList))
141
+ File.open(File.join(path, TagState), 'wb'){|fi| fi.write "000000000\n"}
142
+
143
+ return open(path)
144
+ end # def reset
145
+
146
+
147
+ #####################################
148
+ # Set current revision number for an entry
149
+ #
150
+ # @param enum [Integer] Entry number
151
+ # @param rnum [Integer] Current revision number
152
+ # @raise [Error::Sanity] if state is not open
153
+ # @raise [ArgumentError] if enum or rnum is negative
154
+ # @return [StateFs] self
155
+ def set(enum, rnum)
156
+ raise Error::Sanity, 'State not open' if !@path
157
+ if enum < 0 || rnum < 0
158
+ raise ArgumentError, 'Invalid entry/revision numbers'
159
+ end
160
+
161
+ @file.seek(enum*EntrySize, IO::SEEK_SET)
162
+ @file.write("%09d\n" % rnum)
163
+ return self
164
+ end # def set
165
+
166
+
167
+ #####################################
168
+ # Get current revision number for an entry
169
+ #
170
+ # @param enum [Integer] Entry number
171
+ # @return [Integer] Current revision number
172
+ # @raise [Error::Sanity] if state not open
173
+ # @raise [ArgumentError] if enum is negative
174
+ # @raise [Error::NonExistent] if entry number does not exist
175
+ def get(enum)
176
+ raise Error::Sanity, 'State not open' if !@path
177
+ raise ArgumentError, 'Invalid entry number' if enum < 0
178
+
179
+ @file.seek(enum*EntrySize, IO::SEEK_SET)
180
+ res = @file.read(EntrySize)
181
+ raise Error::NonExistent, 'Entry does not exist' if !res || res[0] == "\x00"
182
+ return res.to_i
183
+ end # def get()
184
+
185
+
186
+ #####################################
187
+ # Read list of tags
188
+ #
189
+ # @return [Array] List of strings containing tag names
190
+ # @raise [Error::Sanity] if state not open
191
+ # @raise [Error::Corrupt] if tag list is missing
192
+ def list
193
+ raise Error::Sanity, 'State not open' if !@path
194
+
195
+ ftn = File.join(@path, TagList)
196
+ begin
197
+ txt = File.read(ftn)
198
+ rescue Errno::ENOENT
199
+ raise Error::Corrupt, 'Unable to read tag list'
200
+ end
201
+ return txt.lines.map{|tg| tg.chomp }
202
+ end # def list
203
+
204
+
205
+ #####################################
206
+ # Read entry numbers from a tag
207
+ #
208
+ # @param name [String] Tag name
209
+ # @param offs [Integer] Offset to begin reading
210
+ # @param max [Integer] Maximum number of entries to return
211
+ # @raise [Error::Sanity] if state not open
212
+ # @raise [Error::NonExistent] if tag does not exist
213
+ # @raise [Error::Corrupt] if file format is bad
214
+ # @return [Array] Total number of entries in the tag, and possibly empty
215
+ # array of entry numbers.
216
+ def tag(name, offs, max)
217
+ raise Error::Sanity, 'State not open' if !@path
218
+
219
+ fn = File.join(@path, name)
220
+ begin
221
+ fi = File.open(fn, 'rb')
222
+ rescue Errno::ENOENT
223
+ raise Error::NonExistent, 'Tag does not exist'
224
+ end
225
+
226
+ ents = []
227
+ size = nil
228
+ begin
229
+ size = fi.size / TagSize
230
+ return [size, ents] if( offs > size || max == 0 )
231
+ num = (offs+max > size) ? (size - offs) : max
232
+ fi.seek(((size-offs-num)*TagSize), IO::SEEK_SET)
233
+ num.times do
234
+ ln = fi.read(TagSize)
235
+ ma = TagReg.match(ln)
236
+ raise Error::Corrupt, 'Bad tag format' unless ma
237
+ ents.push( ma[2].to_i )
238
+ end
239
+ ensure
240
+ fi.close
241
+ end
242
+
243
+ return [size, ents]
244
+ end # def tag()
245
+
246
+
247
+ #####################################
248
+ # Update tags based on new {History}
249
+ #
250
+ # @param cng [Hash] Changes in the format returned by {History#next}
251
+ # @raise [Error::Sanity] if state not open
252
+ # @raise [Error::Corrupt] if file format is bad
253
+ # @raise [Error::Corrupt] if list of tags is missing
254
+ # @raise [Error::Corrupt] if existing tag is missing
255
+ # @return [StateFs] self
256
+ def update(cng)
257
+
258
+ # read list of tags
259
+ changed = false
260
+ thash = {}
261
+ self.list.each{|tag| thash[tag] = true }
262
+
263
+ cng.each do |tag, hc|
264
+ cnt = 0
265
+
266
+ # sorted changes
267
+ se = hc.to_a.select{|en, ti| ti }.sort{|aa, bb| aa[1] <=> bb[1] }
268
+
269
+ # files
270
+ fn = File.join(@path, tag)
271
+ if thash[tag]
272
+ begin
273
+ oldf = File.open(fn, 'rb')
274
+ rescue Errno::ENOENT
275
+ raise Error::Corrupt, 'Existing tag is missing'
276
+ end
277
+ else
278
+ oldf = nil
279
+ end
280
+ newf = Tempfile.new('state', @path, :encoding => 'ASCII-8BIT')
281
+
282
+ # merge new into old file
283
+ while oldf && ln = oldf.read(TagSize)
284
+ unless ma = TagReg.match(ln)
285
+ newf.close!
286
+ raise Error::Corrupt, 'Bad tag format'
287
+ end
288
+
289
+ tme = ma[1]
290
+ enum = ma[2].to_i
291
+
292
+ next if hc.has_key?(enum)
293
+
294
+ while se.size > 0 && tme > se[0][1]
295
+ ne, nt = se.shift
296
+ newf.write("%s %09d\n" % [nt, ne])
297
+ cnt += 1
298
+ end
299
+
300
+ newf.write(ln)
301
+ cnt += 1
302
+ end
303
+ oldf.close() if oldf
304
+
305
+ # write out any remaining new
306
+ while ary = se.shift
307
+ ne, nt = ary
308
+ newf.write("%s %09d\n" % [nt, ne])
309
+ cnt += 1
310
+ end
311
+
312
+ # adjust files
313
+ if cnt == 0
314
+ if oldf
315
+ File.unlink(fn)
316
+ thash.delete(tag)
317
+ changed = true
318
+ end
319
+ else
320
+ FileUtils.ln(newf.path, fn, :force => true)
321
+ if !oldf
322
+ thash[tag] = true
323
+ changed = true
324
+ end
325
+ end
326
+ newf.close!
327
+ end
328
+
329
+ # write list of tags
330
+ if changed
331
+ fnl = File.join(@path, TagList)
332
+ File.open(fnl, 'w', :encoding => 'utf-8') do |fi|
333
+ thash.each_key{|tag| fi.puts tag }
334
+ end
335
+ end
336
+
337
+ return self
338
+ end # def update()
339
+
340
+ end # class StateFs
341
+
342
+ end # module Sgfa
@@ -0,0 +1,214 @@
1
+ #
2
+ # Simple Group of Filing Applications
3
+ # Jacket store using filesystem storage.
4
+ #
5
+ # Copyright (C) 2015 by Graham A. Field.
6
+ #
7
+ # See LICENSE.txt for licensing information.
8
+ #
9
+ # This program is distributed WITHOUT ANY WARRANTY; without even the
10
+ # implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11
+
12
+ require 'tempfile'
13
+ require 'fileutils'
14
+
15
+ require_relative 'error'
16
+
17
+ module Sgfa
18
+
19
+
20
+ #####################################################################
21
+ # Stores copies of {History}, {Entry}, and attached files that are
22
+ # in a {Jacket} using filesystem storage.
23
+ #
24
+ # Storage and retrieval are based on type (i.e. History, Entry, or
25
+ # attached file) and the item hash. The item hash is generated from
26
+ # the {Jacket} id_hash, the type, and the specific number. This allows
27
+ # for a single store to serve as the repository of multiple {Jacket}s
28
+ # if desired (e.g. using a cloud permanent object store).
29
+ #
30
+ # Each item is stored in a file, organized into directories based on the
31
+ # first two characters of the item hash, a file name consisting of the
32
+ # remaining item hash, dash, and a character indicating the type.
33
+ #
34
+ class StoreFs
35
+
36
+
37
+ #####################################
38
+ # Initialize a new store object, optionally opening
39
+ #
40
+ # @param (see #open)
41
+ # @raise (see #open)
42
+ def initialize(path=nil)
43
+ open(path) if path
44
+ end # def initialize()
45
+
46
+
47
+ #####################################
48
+ # Create a new store
49
+ # @param path [String] Path to the directory containing the store
50
+ # @raise [Error::Conflict] if path already exists
51
+ def self.create(path)
52
+ begin
53
+ Dir.mkdir(path)
54
+ rescue Errno::EEXIST
55
+ raise Error::Conflict, 'Store already exists'
56
+ end
57
+ end # def self.create()
58
+
59
+
60
+ #####################################
61
+ # Open the store
62
+ #
63
+ # @param path [String] Path to the directory containing the store
64
+ # @raise [Error::NonExistent] if path does not exist
65
+ # @return [StoreFs] self
66
+ def open(path)
67
+ raise Error::NonExistent, 'Store does not exist' if !File.directory?(path)
68
+ @path = path
69
+ return self
70
+ end # open()
71
+
72
+
73
+ #####################################
74
+ # Close the store
75
+ #
76
+ # @raise [Error::Sanity] if store is not open
77
+ # @return [StoreFs] self
78
+ def close
79
+ raise Error::Sanity, 'Store is not open' if !@path
80
+ @path = nil
81
+ return self
82
+ end # def close
83
+
84
+
85
+ #####################################
86
+ # File name for an item
87
+ #
88
+ # @param type [Symbol] Type of the item
89
+ # @param item [String] Item hash identifier
90
+ # @raise [NotImplementedError] if type is not valid
91
+ # @return [String] the file name
92
+ def _fn(type, item)
93
+ case type
94
+ when :entry then ext = 'e'
95
+ when :history then ext = 'h'
96
+ when :file then ext = 'f'
97
+ else raise NotImplementedError, 'Invalid item type'
98
+ end
99
+
100
+ return '%s/%s/%s-%s' % [@path, item[0,2], item[2..-1], ext]
101
+ end # def _fn()
102
+ private :_fn
103
+
104
+
105
+ #####################################
106
+ # Get a temp file to use to create an item for storage
107
+ #
108
+ # @note Encoding on the file is set to 'utf-8' by default
109
+ # @raise [Error::Sanity] if store not open
110
+ # @return [Tempfile] the temporary file
111
+ def temp
112
+ raise Error::Sanity, 'Store not open' if !@path
113
+ Tempfile.new('blob', @path, :encoding => 'utf-8')
114
+ end # def temp
115
+
116
+
117
+ #####################################
118
+ # Read an item
119
+ #
120
+ # @param (see #_fn)
121
+ # @raise [NotImplementedError] if type is not valid
122
+ # @raise [Error::Sanity] if store not open
123
+ # @return [File] Item opened in read only format or false if not found
124
+ def read(type, item)
125
+ raise Error::Sanity, 'Store not open' if !@path
126
+
127
+ fn = _fn(type, item)
128
+ begin
129
+ fi = File.open(fn, 'r', :encoding => 'utf-8')
130
+ rescue Errno::ENOENT
131
+ return false
132
+ end
133
+ return fi
134
+ end # def read()
135
+
136
+
137
+ #####################################
138
+ # Write an item
139
+ #
140
+ # If content is a string, it will be written to a file and saved.
141
+ # If content is a file it will be deleted from it's current location.
142
+ #
143
+ # @param (see #_fn)
144
+ # @param cont [File, String] Content to store
145
+ # @raise [Error::Sanity] if store not open
146
+ # @raise [NotImplementedError] if type is not valid
147
+ def write(type, item, cont)
148
+ raise Error::Sanity, 'Store not open' if !@path
149
+ fn = _fn(type, item)
150
+
151
+ if cont.is_a?(String)
152
+ tf = temp
153
+ tf.write cont
154
+ cont = tf
155
+ end
156
+
157
+ begin
158
+ FileUtils.ln(cont.path, fn, :force => true)
159
+ rescue Errno::ENOENT
160
+ Dir.mkdir(File.dirname(fn))
161
+ FileUtils.ln(cont.path, fn)
162
+ end
163
+ if cont.respond_to?( :close! )
164
+ cont.close!
165
+ else
166
+ File.unlink(cont, path)
167
+ cont.close
168
+ end
169
+
170
+ return self
171
+ end # def write()
172
+
173
+
174
+ #####################################
175
+ # Delete an item
176
+ #
177
+ # @param (see #_fn)
178
+ # @raise [NotImplementedError] if type is not valid
179
+ # @raise [Error::Sanity] if store not open
180
+ # @return [Boolean] if item deleted
181
+ def delete(type, item)
182
+ raise Error::Sanity, 'Store not open' if !@path
183
+ fn = _fn(type, item)
184
+
185
+ begin
186
+ File.unlink(fn)
187
+ rescue Errno::ENOENT
188
+ return false
189
+ end
190
+ return true
191
+ end # def delete()
192
+
193
+
194
+ #####################################
195
+ # Get size of an item in bytes
196
+ #
197
+ # @param (see #_fn)
198
+ # @raise [NotImplementedError] if type is not valid
199
+ # @raise [Error::Sanity] if store not open
200
+ # @return [Integer, Boolean] Item size, or false if item does not exist
201
+ def size(type, item)
202
+ fn = _fn(type, item)
203
+ begin
204
+ size = File.size(fn)
205
+ rescue Errno::ENOENT
206
+ return false
207
+ end
208
+ return size
209
+ end # def size()
210
+
211
+
212
+ end # class StoreFs
213
+
214
+ end # module Sgfa