chutzen 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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