sevgi-function 0.93.1 → 0.95.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.
data/README.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Sevgi Function
2
2
 
3
- Provides shared helper functions used by Sevgi components.
3
+ Shared helper functions used by Sevgi components.
4
4
 
5
- See the root [README](../README.md) and the documentation site for usage.
5
+ ## Install
6
+
7
+ ```sh
8
+ gem install sevgi-function
9
+ ```
10
+
11
+ ## Require
12
+
13
+ ```ruby
14
+ require "sevgi/function"
15
+ ```
16
+
17
+ ## Example
18
+
19
+ ```ruby
20
+ Sevgi::F.eq?(0.1 + 0.2, 0.3, precision: 12)
21
+ ```
22
+
23
+ ## Ruby compatibility
24
+
25
+ Requires Ruby 3.4.0 or newer. CI verifies Ruby 3.4.0 and the current development Ruby from `.ruby-version`.
26
+
27
+ ## Native prerequisites
28
+
29
+ None beyond Ruby and this gem's Ruby dependencies.
30
+
31
+ ## Links
32
+
33
+ - Documentation: https://sevgi.roktas.dev
34
+ - API documentation: https://www.rubydoc.info/gems/sevgi-function
35
+ - Source: https://github.com/roktas/sevgi/tree/main/function
36
+ - Changelog: https://github.com/roktas/sevgi/blob/main/CHANGELOG.md
data/lib/sevgi/core.rb CHANGED
@@ -45,27 +45,10 @@ module Sevgi
45
45
  # Error raised for invalid public API usage.
46
46
  ArgumentError = Class.new(Error) unless defined?(self::ArgumentError)
47
47
 
48
- # Shared immutable empty array.
49
- EMPTY_ARRAY = [].freeze
50
-
51
- # Shared immutable empty hash.
52
- EMPTY_HASH = {}.freeze
53
-
54
- # Shared immutable empty options hash.
55
- EMPTY_OPTS = {}.freeze
56
-
57
- # Shared empty string.
58
- EMPTY_STRING = ""
59
-
60
- # Identity callable.
61
- IDENTITY = -> (x) { x }.freeze
62
-
63
48
  # Sentinel object used to distinguish an omitted value from nil.
64
49
  Undefined = Object
65
50
  .new
66
51
  .tap do |undefined|
67
- const_set(:Self, -> { Undefined })
68
-
69
52
  # Returns the sentinel name.
70
53
  # @return [String]
71
54
  def undefined.to_s = "Undefined"
@@ -105,7 +88,10 @@ module Sevgi
105
88
  # Returns the first argument that is not {Sevgi::Undefined}.
106
89
  # @param args [Array<Object>] candidate values
107
90
  # @return [Object, nil] first defined value, including nil, or nil when none exists
108
- def undefined.coalesce(*args) = args.find(Self) { |x| !equal?(x) }
91
+ # @example Resolve an optional value
92
+ # Sevgi::Undefined.coalesce(Sevgi::Undefined, nil) # => nil
93
+ # @see #default
94
+ def undefined.coalesce(*args) = args.find { |x| !equal?(x) }
109
95
  end
110
96
  .freeze
111
97
  end
@@ -2,6 +2,16 @@
2
2
 
3
3
  module Sevgi
4
4
  module Function
5
+ # Found file location returned by locate helpers.
6
+ #
7
+ # @!attribute [r] file
8
+ # @return [String] absolute matching file path
9
+ # @!attribute [r] slug
10
+ # @return [String] candidate path that matched
11
+ # @!attribute [r] dir
12
+ # @return [String] directory where the match was found
13
+ Location = Data.define(:file, :slug, :dir)
14
+
5
15
  # Locates one of several candidate files by walking upward from a start directory.
6
16
  class Locate
7
17
  # @overload call(paths, start = Dir.pwd, exclude: nil, &block)
@@ -12,64 +22,83 @@ module Sevgi
12
22
  # @yield optional matcher used instead of file existence checks
13
23
  # @yieldparam path [String] candidate path
14
24
  # @yieldreturn [Boolean]
15
- # @return [Sevgi::Function::Locate::Location, nil] found location, or nil
25
+ # @return [Sevgi::Function::Location, nil] found location, or nil
16
26
  def self.call(*, **, &block) = new(*, **).call(&block)
17
27
 
18
- Location = Data.define(:file, :slug, :dir)
28
+ # Returns candidate paths.
29
+ # @return [Array<String>]
30
+ attr_reader :paths
19
31
 
20
- private_constant :Location
32
+ # Returns the absolute start directory.
33
+ # @return [String]
34
+ attr_reader :start
21
35
 
22
- # @!attribute [r] paths
23
- # @return [Array<String>] candidate paths
24
- # @!attribute [r] start
25
- # @return [String] start directory
26
- # @!attribute [r] exclude
27
- # @return [Array<String>, nil] expanded paths ignored during lookup
28
- attr_reader :paths, :start, :exclude
36
+ # Returns expanded paths ignored during lookup.
37
+ # @return [Array<String>, nil]
38
+ attr_reader :exclude
29
39
 
30
40
  # Builds an upward file locator.
31
41
  # @param paths [Array<String>, String] candidate file paths
32
- # @param start [String] directory where lookup starts
33
- # @param exclude [Array<String>, String, nil] paths ignored during lookup
42
+ # @param start [String] directory where lookup starts, expanded without changing process cwd
43
+ # @param exclude [Array<String>, String, nil] paths ignored during lookup after absolute expansion
34
44
  # @return [void]
35
45
  def initialize(paths, start = ::Dir.pwd, exclude: nil)
36
46
  @paths = Array(paths)
37
- @start = start
47
+ @start = ::File.expand_path(start)
38
48
  @exclude = [*exclude].map { ::File.expand_path(it) } unless exclude.nil?
39
49
  end
40
50
 
41
51
  # Runs the upward lookup.
42
52
  # @yield optional matcher used instead of file existence checks
43
- # @yieldparam path [String] candidate path
53
+ # @yieldparam path [String] absolute candidate path
44
54
  # @yieldreturn [Boolean]
45
- # @return [Sevgi::Function::Locate::Location, nil] found location, or nil
46
- # @raise [Errno::ENOENT] when the start directory cannot be entered
55
+ # @note Absolute exclusions are applied before the default or custom matcher.
56
+ # @return [Sevgi::Function::Location, nil] found location, or nil
57
+ # @raise [Errno::ENOENT] when the start directory does not exist
58
+ # @raise [Errno::ENOTDIR] when the start path is not a directory
47
59
  def call(&block)
48
- origin = ::Dir.pwd
49
- ::Dir.chdir(start)
60
+ validate_start!
50
61
 
51
- here = ::Dir.pwd
52
- until (found = match(&block))
53
- ::Dir.chdir("..")
54
- ::Dir.pwd == here ? return : here = ::Dir.pwd
55
- end
62
+ each_parent(start) do |here|
63
+ next unless (found = match(here, &block))
56
64
 
57
- Location[::File.expand_path(found, here), found, here]
58
- ensure
59
- ::Dir.chdir(origin)
65
+ slug, file = found
66
+
67
+ return Location[file, slug, here]
68
+ end
60
69
  end
61
70
 
62
71
  private
63
72
 
64
- def match(&block)
65
- finder = block ||
66
- if exclude.nil?
67
- proc { |path| ::File.exist?(path) }
68
- else
69
- proc { |path| !exclude.include?(::File.expand_path(path)) && ::File.exist?(path) }
70
- end
73
+ def each_parent(here)
74
+ loop do
75
+ yield here
76
+
77
+ parent = ::File.dirname(here)
78
+ break if parent == here
79
+
80
+ here = parent
81
+ end
82
+ end
83
+
84
+ def excluded?(candidate)
85
+ exclude&.include?(candidate)
86
+ end
87
+
88
+ def match(here, &block)
89
+ paths.each do |path|
90
+ candidate = ::File.expand_path(path, here)
91
+ next if excluded?(candidate)
92
+
93
+ return [path, candidate] if (block || proc { ::File.exist?(it) }).call(candidate)
94
+ end
95
+
96
+ nil
97
+ end
71
98
 
72
- paths.find { finder.call(it) }
99
+ def validate_start!
100
+ raise ::Errno::ENOENT, start unless ::File.exist?(start)
101
+ raise ::Errno::ENOTDIR, start unless ::File.directory?(start)
73
102
  end
74
103
  end
75
104
 
@@ -78,7 +107,7 @@ module Sevgi
78
107
  # @param start [String] directory where lookup starts
79
108
  # @param exclude [Array<String>, String, nil] paths ignored during lookup
80
109
  # @param extension [String] default extension added before lookup
81
- # @return [Sevgi::Function::Locate::Location] found location
110
+ # @return [Sevgi::Function::Location] found location
82
111
  # @raise [Sevgi::Error] when no matching file exists
83
112
  def self.locate(filename, start, exclude: nil, extension: EXTENSION)
84
113
  Locate.(F.qualify(filename, extension), start, exclude:).tap do |path|
@@ -74,11 +74,17 @@ module Sevgi
74
74
  def cot(degrees) = 1.0 / ::Math.tan(to_radians(degrees))
75
75
 
76
76
  # Counts complete divisions in a length.
77
- # @param length [Numeric] total length
78
- # @param division [Numeric] division size
77
+ # @param length [Numeric] finite total length
78
+ # @param division [Numeric] finite, non-zero division size
79
79
  # @return [Integer]
80
- # @raise [ZeroDivisionError] when division is zero
81
- def count(length, division) = (length / division.to_f).to_i
80
+ # @raise [Sevgi::ArgumentError] when an operand is not a finite Numeric or division is zero
81
+ def count(length, division)
82
+ length = finite_real(:length, length)
83
+ divisor = finite_real(:division, division)
84
+ ArgumentError.("Division must not be zero") if divisor.zero?
85
+
86
+ (length / divisor).to_i
87
+ end
82
88
 
83
89
  # Compares two numeric values after approximate rounding.
84
90
  # @param left [Numeric] left operand
@@ -162,6 +168,25 @@ module Sevgi
162
168
  # @param precision [Integer, nil] explicit precision, or nil to use {Math.precision}
163
169
  # @return [Boolean]
164
170
  def zero?(value, precision: nil) = eq?(value, 0.0, precision:)
171
+
172
+ private
173
+
174
+ def finite_real(field, value)
175
+ unless value.is_a?(::Numeric)
176
+ ArgumentError.("#{field} must be a finite Numeric: #{value.inspect}")
177
+ end
178
+
179
+ number = begin
180
+ value.to_f
181
+ rescue ::StandardError => e
182
+ ArgumentError.("#{field} must be a finite Numeric: #{value.inspect} (#{e.message})")
183
+ end
184
+
185
+ ArgumentError.("#{field} must be finite: #{value.inspect}") unless number.is_a?(::Float) && number.finite?
186
+
187
+ number
188
+ end
189
+
165
190
  end
166
191
 
167
192
  extend Math
@@ -6,17 +6,17 @@ module Sevgi
6
6
  module Function
7
7
  # Shell execution helpers and executable lookup utilities.
8
8
  module Shell
9
- # Checks whether a program exists in PATH.
10
- # @param program [Object] program name
11
- # @return [Boolean] true when an executable with this name is found
9
+ # Checks whether a program exists and is executable.
10
+ # @param program [Object] program name, absolute path, or relative slash-containing path
11
+ # @return [Boolean] true when an executable regular file is found
12
+ # @note PATH is evaluated on every call; empty PATH segments mean the current directory.
12
13
  def executable?(program)
13
14
  program = program.to_s
14
15
  return false if program.empty?
16
+ return executable_file?(program) if slash_path?(program)
15
17
 
16
- executable_cache.fetch(program) do
17
- executable_cache[program] = ENV.fetch("PATH", "").split(::File::PATH_SEPARATOR).any? do |dir|
18
- ::File.executable?(::File.join(dir, program))
19
- end
18
+ ENV.fetch("PATH", "").split(::File::PATH_SEPARATOR, -1).any? do |dir|
19
+ executable_file?(::File.join(dir.empty? ? "." : dir, program))
20
20
  end
21
21
  end
22
22
 
@@ -24,8 +24,9 @@ module Sevgi
24
24
  # @param args [Array<Object>] command arguments
25
25
  # @return [nil]
26
26
  # @raise [Sevgi::Error] when the program cannot be found in PATH
27
+ # @note The first argument is checked as one exact argv entry; it is never shell-split.
27
28
  def executable!(*args)
28
- program = args.first.to_s.split.first
29
+ program = args.first.to_s
29
30
  Error.("Missing executable: #{program}") unless executable?(program)
30
31
  end
31
32
 
@@ -73,6 +74,101 @@ module Sevgi
73
74
  def self.dummy = new([], [], [], 0)
74
75
  end
75
76
 
77
+ # Shared process-global SIGINT state for overlapping shell runners.
78
+ # @api private
79
+ module Signals
80
+ Entry = Data.define(:runner, :pid)
81
+ WAKE = "."
82
+
83
+ private_constant :WAKE
84
+
85
+ class << self
86
+ def register(runner, pid)
87
+ mutex.synchronize do
88
+ install unless entries.any?
89
+ entries[runner] = Entry.new(runner, pid)
90
+ end
91
+ end
92
+
93
+ def unregister(runner)
94
+ worker = mutex.synchronize do
95
+ next unless entries.delete(runner)
96
+
97
+ restore unless entries.any?
98
+ end
99
+
100
+ worker.join if worker && worker != Thread.current
101
+ end
102
+
103
+ private
104
+
105
+ def dispatch
106
+ active = mutex.synchronize { entries.values.dup }
107
+ active.each { dispatch_entry(it) }
108
+ end
109
+
110
+ def dispatch_entry(entry)
111
+ entry.runner.send(:handle_sigint, entry.pid)
112
+ rescue ::StandardError => e
113
+ warn("SIGINT dispatch failed: #{e.message}")
114
+ end
115
+
116
+ def install
117
+ reader, writer = ::IO.pipe
118
+ worker = Thread.new { listen(reader) }
119
+ previous = Signal.trap("INT") { notify(writer) }
120
+
121
+ @previous = previous
122
+ @worker = worker
123
+ @writer = writer
124
+ rescue ::StandardError
125
+ Signal.trap("INT", previous) if previous
126
+ close_io(writer)
127
+ worker&.join
128
+ close_io(reader)
129
+ raise
130
+ end
131
+
132
+ def listen(reader)
133
+ dispatch while reader.read(1)
134
+ rescue ::IOError, ::SystemCallError
135
+ nil
136
+ ensure
137
+ close_io(reader)
138
+ end
139
+
140
+ def mutex = @mutex ||= Mutex.new
141
+
142
+ def entries = @entries ||= {}
143
+
144
+ def notify(writer)
145
+ writer.write_nonblock(WAKE, exception: false)
146
+ rescue ::IOError, ::SystemCallError
147
+ nil
148
+ end
149
+
150
+ def restore
151
+ Signal.trap("INT", @previous)
152
+ @previous = nil
153
+
154
+ writer = @writer
155
+ worker = @worker
156
+ @worker = nil
157
+ @writer = nil
158
+ close_io(writer)
159
+ worker
160
+ end
161
+
162
+ def close_io(io)
163
+ io&.close unless io&.closed?
164
+ rescue ::IOError
165
+ nil
166
+ end
167
+ end
168
+ end
169
+
170
+ private_constant :Signals
171
+
76
172
  # Runs shell commands and captures stdout, stderr, and exit status.
77
173
  # @api private
78
174
  class Runner
@@ -91,12 +187,9 @@ module Sevgi
91
187
  def call(*args, &input)
92
188
  return Result.dummy if args.empty?
93
189
 
190
+ @coathooks = 0
94
191
  outs, errs, status = Open3.popen3(*args) do |stdin, stdout, stderr, wait_thread|
95
- content = input.call if input
96
- stdin.write(content) if content
97
- stdin.close
98
-
99
- capture(stdout, stderr, wait_thread)
192
+ capture(stdin, stdout, stderr, wait_thread, &input)
100
193
  end
101
194
 
102
195
  Result.new(args, outs, errs, status.exitstatus)
@@ -104,19 +197,48 @@ module Sevgi
104
197
 
105
198
  private
106
199
 
107
- def capture(stdout, stderr, wait_thread)
108
- # Handle `^C`
109
- previous = trap("INT") { handle_sigint(wait_thread.pid) }
200
+ # rubocop:disable Lint/RescueException
201
+ def capture(stdin, stdout, stderr, wait_thread, &input)
202
+ registered = false
203
+ Signals.register(self, wait_thread.pid)
204
+ registered = true
205
+ readers = start_readers(stdout, stderr)
110
206
 
111
- outs = Thread.new { stdout.readlines.map(&:chomp) }
112
- errs = Thread.new { stderr.readlines.map(&:chomp) }
113
-
114
- [outs.value, errs.value, wait_thread.value]
207
+ read_process(stdin, wait_thread, readers, &input)
208
+ rescue Exception
209
+ cleanup_failed_capture(stdin, wait_thread, readers)
210
+ raise
115
211
  ensure
116
- trap("INT", previous) if previous
212
+ close_input(stdin)
213
+ Signals.unregister(self) if registered
214
+ end
215
+ # rubocop:enable Lint/RescueException
216
+
217
+ def start_readers(stdout, stderr)
218
+ [
219
+ Thread.new { stdout.readlines.map(&:chomp) },
220
+ Thread.new { stderr.readlines.map(&:chomp) }
221
+ ]
222
+ end
223
+
224
+ def read_process(stdin, wait_thread, readers, &input)
225
+ write_input(stdin, &input)
226
+ close_input(stdin)
227
+
228
+ status = wait_thread.value
229
+
230
+ [readers[0].value, readers[1].value, status]
231
+ end
232
+
233
+ def cleanup_failed_capture(stdin, wait_thread, readers)
234
+ close_input(stdin)
235
+ stop_process(wait_thread)
236
+ join_readers(readers)
117
237
  end
118
238
 
119
239
  def handle_sigint(pid)
240
+ @coathooks += 1
241
+
120
242
  message, signal = if @coathooks > 1
121
243
  ["SIGINT received again. Force quitting...", "KILL"]
122
244
  else
@@ -126,28 +248,68 @@ module Sevgi
126
248
  warn
127
249
  warn(message)
128
250
  ::Process.kill(signal, pid)
129
- @coathooks += 1
130
251
  rescue Errno::ESRCH
131
252
  warn("No process to kill.")
132
253
  end
254
+
255
+ def write_input(stdin)
256
+ return unless block_given?
257
+
258
+ content = yield
259
+ stdin.write(content) if content
260
+ end
261
+
262
+ def close_input(stdin)
263
+ stdin.close unless stdin.closed?
264
+ rescue IOError
265
+ nil
266
+ end
267
+
268
+ def stop_process(wait_thread)
269
+ return unless wait_thread&.alive?
270
+
271
+ kill_process("TERM", wait_thread.pid)
272
+ return if wait_thread.join(1)
273
+
274
+ kill_process("KILL", wait_thread.pid)
275
+ wait_thread.join
276
+ end
277
+
278
+ def kill_process(signal, pid)
279
+ ::Process.kill(signal, pid)
280
+ rescue Errno::ESRCH
281
+ nil
282
+ end
283
+
284
+ def join_readers(readers)
285
+ Array(readers).each(&:join)
286
+ end
133
287
  end
134
288
 
135
289
  # @overload sh(*args, &block)
136
290
  # Runs a command and captures stdout, stderr, and exit status.
137
291
  # @param args [Array<String>] command and arguments
138
- # @yield optional content writer for stdin
139
- # @yieldreturn [String, nil]
292
+ # @yield optional stdin producer, evaluated once after output readers start
293
+ # @yieldreturn [String, nil] content to write to stdin; nil writes nothing
140
294
  # @return [Sevgi::Function::Shell::Result]
141
- # @raise [Errno::ENOENT] when the executable cannot be spawned
295
+ # @raise [SystemCallError] when the executable cannot be spawned or process pipes cannot be opened
296
+ # @raise [StandardError] when the input block raises; the child is terminated and reaped before propagation
297
+ # @note The child's stdin is closed after the input block. During execution, the first SIGINT sends TERM to the
298
+ # child process and the second SIGINT as KILL to each active child outside trap context, then restores the
299
+ # previous handler.
142
300
  def sh(...) = Runner.new.(...)
143
301
 
144
302
  # Runs a command, requiring both executable lookup and successful exit status.
145
303
  # @param args [Array<String>] command and arguments
146
- # @yield optional content writer for stdin
147
- # @yieldreturn [String, nil]
304
+ # @yield optional stdin producer, evaluated once after output readers start
305
+ # @yieldreturn [String, nil] content to write to stdin; nil writes nothing
148
306
  # @return [Sevgi::Function::Shell::Result]
149
307
  # @raise [Sevgi::Error] when the executable is missing or the command fails
150
- # @raise [Errno::ENOENT] when the executable cannot be spawned
308
+ # @raise [SystemCallError] when the executable cannot be spawned or process pipes cannot be opened
309
+ # @raise [StandardError] when the input block raises; the child is terminated and reaped before propagation
310
+ # @note The child's stdin is closed after the input block. During execution, the first SIGINT sends TERM to the
311
+ # child process and the second SIGINT as KILL to each active child outside trap context, then restores the previous
312
+ # handler.
151
313
  def sh!(*args, &block)
152
314
  executable!(*args) unless args.empty?
153
315
 
@@ -163,7 +325,13 @@ module Sevgi
163
325
 
164
326
  private
165
327
 
166
- def executable_cache = @executable_cache ||= {}
328
+ def executable_file?(path)
329
+ ::File.file?(path) && ::File.executable?(path)
330
+ end
331
+
332
+ def slash_path?(program)
333
+ program.include?(::File::SEPARATOR) || (::File::ALT_SEPARATOR && program.include?(::File::ALT_SEPARATOR))
334
+ end
167
335
  end
168
336
 
169
337
  extend Shell
@@ -22,19 +22,17 @@ module Sevgi
22
22
  # Lightweight English pluralization helper.
23
23
  module Pluralize
24
24
  # Words that should not be pluralized.
25
- UNCOUNTABLES = Hash[
26
- *%w[
27
- sheep
28
- fish
29
- sheep
30
- series
31
- species
32
- money
33
- rice
34
- information
35
- equipment
36
- ].map { [it, true] }.flatten
25
+ UNCOUNTABLES = %w[
26
+ equipment
27
+ fish
28
+ information
29
+ money
30
+ rice
31
+ series
32
+ sheep
33
+ species
37
34
  ]
35
+ .to_h { [it, true] }
38
36
  .freeze
39
37
 
40
38
  # Singular-to-plural forms that do not follow suffix rules.
@@ -99,7 +97,7 @@ module Sevgi
99
97
  def pluralize(word)
100
98
  result = word.to_s.dup
101
99
 
102
- return result if word.empty? || UNCOUNTABLES.key?(result) || PLURALS.key?(result)
100
+ return result if result.empty? || UNCOUNTABLES.key?(result) || PLURALS.key?(result)
103
101
  return IRREGULARS[result] if IRREGULARS.key?(result)
104
102
 
105
103
  RULES.each { |(rule, replacement)| break if result.sub!(rule, replacement) }
@@ -3,6 +3,6 @@
3
3
  module Sevgi
4
4
  module Function
5
5
  # Current version of the Sevgi function gem.
6
- VERSION = "0.93.1"
6
+ VERSION = "0.95.0"
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sevgi-function
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.93.1
4
+ version: 0.95.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Recai Oktaş
@@ -15,6 +15,8 @@ executables: []
15
15
  extensions: []
16
16
  extra_rdoc_files: []
17
17
  files:
18
+ - CHANGELOG.md
19
+ - LICENSE
18
20
  - README.md
19
21
  - lib/sevgi/core.rb
20
22
  - lib/sevgi/function.rb
@@ -41,14 +43,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
41
43
  requirements:
42
44
  - - ">="
43
45
  - !ruby/object:Gem::Version
44
- version: 3.4.0.pre.preview1
46
+ version: 3.4.0
45
47
  required_rubygems_version: !ruby/object:Gem::Requirement
46
48
  requirements:
47
49
  - - ">="
48
50
  - !ruby/object:Gem::Version
49
51
  version: '0'
50
52
  requirements: []
51
- rubygems_version: 4.0.10
53
+ rubygems_version: 4.0.11
52
54
  specification_version: 4
53
55
  summary: Various utility functions for the Sevgi toolkit.
54
56
  test_files: []