chutzen 0.8.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,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chutzen
4
+ # Holds a hash and allows retrieval of data with a simple lookup syntax.
5
+ class Dictionary
6
+ # Creates a new Dictionary with an option initial hash of data.
7
+ def initialize(data = {})
8
+ @data = data
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def to_hash
13
+ @data
14
+ end
15
+
16
+ # Get a value using a dot expression (eg. 'export.filename'). Does not work with deep search
17
+ # indentifiers.
18
+ def [](key)
19
+ dig(*Chutzen::Expression.split(key))
20
+ end
21
+
22
+ def []=(key, value)
23
+ @mutex.synchronize do
24
+ @data[key.to_s] = value
25
+ end
26
+ end
27
+
28
+ # Returns true when a value was set for the path, so that means it also returns true when the
29
+ # value is nil.
30
+ def exist?(*path)
31
+ data = path.length > 1 ? @data.dig(*path[0..-2]) : @data
32
+ data&.key?(path.last)
33
+ end
34
+
35
+ # See Hash#dig.
36
+ def dig(*path)
37
+ @data.dig(*path)
38
+ end
39
+
40
+ # Performs a depth first search for the first item in the path. After that it works just like
41
+ # dig on the rest of the path.
42
+ def dig_deep(*path)
43
+ found_path = self.class.search(@data, path[0])
44
+ found_path ? @data.dig(*(found_path.reverse + path[1..])) : nil
45
+ end
46
+
47
+ # Add or replace a value with a path.
48
+ def bury!(path, value)
49
+ @mutex.synchronize do
50
+ self.class.bury(@data, path, value)
51
+ end
52
+ end
53
+
54
+ # Merge a hash into the dictionary.
55
+ def merge!(data)
56
+ @mutex.synchronize do
57
+ @data.merge!(data)
58
+ end
59
+ end
60
+
61
+ # Merge a hash into the dictionary without replacing existing values.
62
+ def deep_merge!(data)
63
+ @mutex.synchronize do
64
+ DeepMerge.new(@data, data).perform
65
+ end
66
+ end
67
+
68
+ # Searches the data depth-first for a key and returns the path to that
69
+ # key for the first match.
70
+ def self.search(data, key)
71
+ case data
72
+ when Hash
73
+ search_hash(data, key)
74
+ when Array
75
+ search_array(data, key)
76
+ end
77
+ end
78
+
79
+ def self.search_hash(data, key)
80
+ if data.key?(key)
81
+ [key]
82
+ else
83
+ data.each do |name, value|
84
+ path = search(value, key)
85
+ return (path << name) if path
86
+ end
87
+ nil
88
+ end
89
+ end
90
+
91
+ def self.search_array(data, key)
92
+ data.each.with_index do |item, index|
93
+ path = search(item, key)
94
+ return (path << index) if path
95
+ end
96
+ nil
97
+ end
98
+
99
+ def self.bury(data, path, value)
100
+ key, *rest = path
101
+ if rest.empty?
102
+ data[key] = value
103
+ elsif data.key?(key)
104
+ bury(data[key], rest, value)
105
+ else
106
+ data[key] = build(rest, value)
107
+ end
108
+ end
109
+
110
+ def self.build(path, value)
111
+ reference, *rest = path
112
+ if reference.is_a?(Numeric) && reference < 256
113
+ build_array(reference, rest, value)
114
+ else
115
+ build_hash(reference, rest, value)
116
+ end
117
+ end
118
+
119
+ def self.build_array(index, rest, value)
120
+ if rest.empty?
121
+ [].insert(index, value)
122
+ else
123
+ [].insert(index, build(rest, value))
124
+ end
125
+ end
126
+
127
+ def self.build_hash(key, rest, value)
128
+ if rest.empty?
129
+ { key => value }
130
+ else
131
+ { key => build(rest, value) }
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chutzen
4
+ class Expression
5
+ # Raised when Chutzen can't find a value in the job dictionary.
6
+ class LookupError < Chutzen::StandardError
7
+ attr_reader :expression, :dictionary
8
+
9
+ def initialize(message, expression: nil, dictionary: nil)
10
+ super(message)
11
+ @expression = expression
12
+ @dictionary = dictionary
13
+ end
14
+
15
+ def as_json
16
+ { 'error' => details }
17
+ end
18
+
19
+ private
20
+
21
+ def details
22
+ {
23
+ 'message' => message,
24
+ 'expression' => @expression&.to_s,
25
+ 'dictionary' => @dictionary&.to_hash
26
+ }.compact
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chutzen
4
+ class Expression
5
+ # Raised when something went wrong while parsing a Chutzen expression.
6
+ class SyntaxError < Chutzen::StandardError
7
+ attr_reader :expression
8
+
9
+ def initialize(message, expression: nil)
10
+ super(message)
11
+ @expression = expression
12
+ end
13
+
14
+ def as_json
15
+ { 'error' => details }
16
+ end
17
+
18
+ private
19
+
20
+ def details
21
+ {
22
+ 'message' => message,
23
+ 'expression' => @expression&.to_s
24
+ }.compact
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parslet'
4
+
5
+ module Chutzen
6
+ # Transforms a Chutzen expression by transforming it and replacing any query expressions with
7
+ # their values.
8
+ class Expression
9
+ autoload :SyntaxError, 'chutzen/expression/syntax_error'
10
+ autoload :LookupError, 'chutzen/expression/lookup_error'
11
+
12
+ INTEGER_RE = /\A\d+\z/.freeze
13
+ OPERATIONS = {
14
+ '=' => ->(left, right) { left == right },
15
+ '&' => ->(left, right) { left & right },
16
+ '|' => ->(left, right) { left | right },
17
+ '<' => ->(left, right) { left & right ? left < right : nil },
18
+ '>' => ->(left, right) { left & right ? left > right : nil }
19
+ }.freeze
20
+
21
+ Transformer = Parslet::Transform.new do
22
+ rule(undefined: simple(:value)) { nil }
23
+ rule(boolean: simple(:value)) { value.to_s == 'true' }
24
+ rule(integer: simple(:value)) { value.to_i }
25
+ rule(float: simple(:value)) { value.to_f }
26
+ rule(string: simple(:value)) { value.to_s }
27
+
28
+ rule(identifier: simple(:name)) do
29
+ path = *Chutzen::Expression.split(name)
30
+ if dictionary.exist?(*path)
31
+ dictionary.dig(*path)
32
+ else
33
+ raise(
34
+ LookupError.new(
35
+ "No value for #{name}", expression: name.to_s, dictionary: dictionary
36
+ )
37
+ )
38
+ end
39
+ end
40
+
41
+ rule(search_identifier: simple(:name)) do
42
+ dictionary.dig_deep(*Chutzen::Expression.split(name))
43
+ end
44
+
45
+ rule(
46
+ operation: {
47
+ left: simple(:left), operator: simple(:operator), right: simple(:right)
48
+ }
49
+ ) do
50
+ OPERATIONS[operator.to_s].call(left, right)
51
+ end
52
+ end
53
+
54
+ def initialize(expression, dictionary)
55
+ @expression = Chutzen::ExpressionParser.new.parse(expression)
56
+ @dictionary = dictionary
57
+ rescue Parslet::ParseFailed => e
58
+ raise Chutzen::Expression::SyntaxError.new(
59
+ e.parse_failure_cause.ascii_tree,
60
+ expression: expression
61
+ )
62
+ end
63
+
64
+ def result
65
+ Transformer.apply(@expression, dictionary: @dictionary)
66
+ end
67
+
68
+ def self.split(key)
69
+ key.to_s.split('.').map do |segment|
70
+ INTEGER_RE.match(segment) ? segment.to_i : segment
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parslet'
4
+
5
+ module Chutzen
6
+ # Parses an expression in the Chutzen language.
7
+ class ExpressionParser < Parslet::Parser
8
+ rule(:spaces) { match('\s').repeat(1) }
9
+ rule(:spaces?) { spaces.maybe }
10
+
11
+ rule(:numeric) { match('[0-9]').repeat(1) }
12
+ rule(:integer) { numeric.as(:integer) }
13
+ rule(:float) { ((numeric >> str('.')).repeat(1) >> numeric).as(:float) }
14
+
15
+ rule(:undefined) { str('undefined').as(:undefined) }
16
+ rule(:boolean) { (str('true') | str('false')).as(:boolean) }
17
+
18
+ rule(:quote) { str("'") }
19
+ rule(:string) { match("[^']").repeat(1) }
20
+ rule(:quoted_string) { quote >> string.as(:string) >> quote }
21
+
22
+ rule(:literal) { undefined | float | integer | boolean | quoted_string }
23
+
24
+ rule(:search) { str('//') }
25
+ rule(:key) { match['\w'].repeat(1) }
26
+ rule(:atom) { key | integer }
27
+ rule(:absolute_identifier) { atom >> (str('.') >> atom).repeat }
28
+ rule(:search_identifier) { search >> absolute_identifier.as(:search_identifier) }
29
+ rule(:identifier) do
30
+ search_identifier | absolute_identifier.as(:identifier)
31
+ end
32
+
33
+ rule(:simple_expression) { literal | identifier }
34
+
35
+ rule(:operator) { match('[&|=<>]') }
36
+ rule(:operation) do
37
+ simple_expression.as(:left) >> spaces? >>
38
+ operator.as(:operator) >> spaces? >>
39
+ simple_expression.as(:right)
40
+ end
41
+
42
+ rule(:expression) do
43
+ operation.as(:operation) | simple_expression
44
+ end
45
+ root(:expression)
46
+ end
47
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'etc'
4
+
5
+ module Chutzen
6
+ # Holds a description for a job and executes it.
7
+ class Job
8
+ include Enumerable
9
+
10
+ def initialize(description)
11
+ @notify = nil
12
+ @result = {}
13
+ description.each do |name, value|
14
+ instance_variable_set("@#{name}", value)
15
+ end
16
+ @uuid = SecureRandom.uuid
17
+ @work_path = File.join(ENV['HOME'], 'chutzen', @uuid)
18
+ @dictionary = Dictionary.new(properties)
19
+ end
20
+
21
+ def properties
22
+ {
23
+ 'uuid' => @uuid,
24
+ 'work_path' => @work_path,
25
+ 'uname' => Etc.uname.transform_keys(&:to_s),
26
+ 'nprocs' => Etc.nprocessors,
27
+ 'chutzen_version' => Chutzen::VERSION
28
+ }
29
+ end
30
+
31
+ # Yields Command instances for each command in the description.
32
+ def each
33
+ @commands.each do |description|
34
+ yield Command.new(
35
+ description,
36
+ dictionary: @dictionary,
37
+ work_path: @work_path
38
+ )
39
+ end
40
+ end
41
+
42
+ # Perform all commands in the job.
43
+ def perform
44
+ FileUtils.mkdir_p(@work_path)
45
+ map do |command|
46
+ perform_notification(command.perform)
47
+ command
48
+ end
49
+ rescue Chutzen::StandardError => e
50
+ warn("Job failed: #{e}")
51
+ perform_exception_notification(e)
52
+ ensure
53
+ FileUtils.rm_rf(@work_path)
54
+ end
55
+
56
+ private
57
+
58
+ def global_result
59
+ @result || {}
60
+ end
61
+
62
+ # Pushes a notification into the Sidekiq queue for each command that
63
+ # produces a result.
64
+ def perform_notification(command)
65
+ return unless @notify
66
+ return unless command
67
+
68
+ result = command.result
69
+ return unless result
70
+
71
+ Notification.perform_async(@notify, global_result.merge(result))
72
+ end
73
+
74
+ def perform_exception_notification(error)
75
+ return unless @notify
76
+
77
+ Notification.perform_async(@notify, global_result.merge(error.as_json))
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chutzen
4
+ # Helper class to push a Sidekiq job on the specified callback queue with the
5
+ # specified class and arguments.
6
+ class Notification
7
+ # Returns defaults for a Sidekiq job. See Sidekiq documentation for value
8
+ # params. We suggest using 'queue', 'class', and 'args'.
9
+ def self.defaults
10
+ { 'queue' => 'default' }
11
+ end
12
+
13
+ # Schedules a notification by creating a Sidekiq job using the job
14
+ # description and a Ruby object as a payload.
15
+ #
16
+ # Notification.perform_async(
17
+ # { 'class' => 'MyApplication::Result' },
18
+ # { 'filename' => 'ok.txt', 'name' => 'ok' }
19
+ # )
20
+ #
21
+ # The payload is serialized as JSON to reduce potential interoperability
22
+ # problems and to make it easier to write forward and backward support
23
+ # for the worker classes.
24
+ def self.perform_async(job, payload)
25
+ Sidekiq::Client.push(
26
+ defaults.merge(
27
+ job.merge('args' => [JSON.dump(payload)])
28
+ )
29
+ )
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chutzen
4
+ # Raised when something went wrong while evaluating a Chutzen expression.
5
+ class RuntimeError < Chutzen::StandardError
6
+ def initialize(message, **details)
7
+ super(message)
8
+ @details = details
9
+ end
10
+
11
+ def as_json
12
+ { 'error' => details }
13
+ end
14
+
15
+ private
16
+
17
+ def details
18
+ { 'message' => message }.merge(@details.transform_keys(&:to_s))
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chutzen
4
+ # Implements utilities to safely run commands on the shell.
5
+ class Shell
6
+ def self.join(execute)
7
+ case execute
8
+ when Hash
9
+ join_hash(execute)
10
+ when Array
11
+ join_array(execute)
12
+ else
13
+ execute
14
+ end
15
+ end
16
+
17
+ def self.join_hash(execute)
18
+ execute.map do |name, value|
19
+ value ? "#{name} #{value}" : name
20
+ end.join(' ')
21
+ end
22
+
23
+ def self.join_array(execute)
24
+ execute.map { |part| join(part) }.join(' ')
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sidekiq'
4
+ require 'sidekiq/cli'
5
+
6
+ module Chutzen
7
+ # Sidekiq worker to accept operational signals and commands through the queue.
8
+ class Signal
9
+ include Sidekiq::Worker
10
+
11
+ sidekiq_options queue: Chutzen.sidekiq_queue, retry: false
12
+
13
+ # Send a signal to the Chutzen process.
14
+ #
15
+ # == Signal names
16
+ #
17
+ # * +stop+ stops the current process from accepting new jobs
18
+ #
19
+ # @param signal [string] the signal you want to send
20
+ # @param expires_at [integer] ignore signal after this time (in seconds
21
+ # since 1970)
22
+ def perform(signal, expires_at = nil)
23
+ if fresh?(expires_at)
24
+ perform_signal(signal)
25
+ else
26
+ Sidekiq.logger.info("Ignoring signal `#{signal}' because it expired at #{expires_at}.")
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def fresh?(expires_at)
33
+ if expires_at
34
+ Time.at(expires_at) > Time.now
35
+ else
36
+ true
37
+ end
38
+ end
39
+
40
+ def perform_signal(signal)
41
+ case signal
42
+ when 'stop'
43
+ Sidekiq::CLI.instance.handle_signal('TSTP')
44
+ else
45
+ raise(ArgumentError, "Unknown signal `#{signal}'")
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chutzen
4
+ # Base class for all exceptions in Chutzen.
5
+ class StandardError < ::StandardError
6
+ def as_json
7
+ { 'error' => { 'message' => message } }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chutzen
4
+ class Template
5
+ # Raised when something went wrong while parsing a Chutzen template.
6
+ class SyntaxError < Chutzen::StandardError
7
+ attr_reader :template
8
+
9
+ def initialize(message, template: nil)
10
+ super(message)
11
+ @template = template
12
+ end
13
+
14
+ def as_json
15
+ { 'error' => details }
16
+ end
17
+
18
+ private
19
+
20
+ def details
21
+ {
22
+ 'message' => message,
23
+ 'template' => @template&.to_s
24
+ }.compact
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parslet'
4
+
5
+ module Chutzen
6
+ # Replaces Chutzen queries and variable templates in a text template using a dictionary of
7
+ # values.
8
+ class Template
9
+ autoload :SyntaxError, 'chutzen/template/syntax_error'
10
+
11
+ Transformer = Parslet::Transform.new do
12
+ rule(expression: simple(:expression)) do
13
+ Chutzen::Expression.new(expression.to_s, dictionary).result
14
+ end
15
+ rule(global: simple(:name)) { dictionary[name.to_s] }
16
+ rule(string: simple(:value)) { value.to_s }
17
+ end
18
+
19
+ def initialize(template, dictionary)
20
+ @template = Chutzen::TemplateParser.new.parse(template)
21
+ @dictionary = dictionary
22
+ rescue Parslet::ParseFailed => e
23
+ raise Chutzen::Template::SyntaxError.new(
24
+ e.parse_failure_cause.ascii_tree,
25
+ template: template
26
+ )
27
+ end
28
+
29
+ def result
30
+ result = Transformer.apply(@template, dictionary: @dictionary)
31
+ case result
32
+ when Array
33
+ result.join
34
+ else
35
+ result
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parslet'
4
+
5
+ module Chutzen
6
+ # Parses a template for variable expressions.
7
+ class TemplateParser < Parslet::Parser
8
+ rule(:string) { match('[^$]').repeat(1).as(:string) }
9
+
10
+ rule(:variable) { str('${') >> match('\\\}|[^}]').repeat(1).as(:expression) >> str('}') }
11
+ rule(:global) { str('@') >> match('\w').repeat(1).as(:global) }
12
+ rule(:part) { variable | string }
13
+
14
+ rule(:template) { str('') | global | part.repeat(1) }
15
+ root(:template)
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chutzen
4
+ VERSION = '0.8.0'
5
+ end