notepadqq_api 0.1.3 → 0.1.4
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.
- checksums.yaml +4 -4
- data/lib/notepadqq_api/message_channel.rb +21 -21
- data/lib/notepadqq_api/message_interpreter.rb +69 -32
- data/lib/notepadqq_api/stubs.rb +6 -6
- data/lib/notepadqq_api.rb +23 -17
- data/test/test_message_interpreter.rb +16 -15
- data/test/test_stubs.rb +10 -10
- metadata +17 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1529b331a85e339351d5002099908bdcb33d528c
|
4
|
+
data.tar.gz: 5222b4cef0fa9bd0b50d8db409cf7571588fbb54
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 727a0d4bb12fd1bf7e588d0e4853b2ce646eab9fa65198f9d90318b579405a3cffc67ff0d0f35f28ce67bc763c003210aa75e58c405e6d0b37ccfd80ada05832
|
7
|
+
data.tar.gz: c8c91d50253b9344a377a6bb5e42d84507f5e7116f6ba877d5dd90a63ea8180d178cf95a09f85419ab5a5aea6e421664952f8ad6eaf2e4004e3220241eb4ae84
|
@@ -4,24 +4,24 @@ require 'json'
|
|
4
4
|
class NotepadqqApi
|
5
5
|
class MessageChannel
|
6
6
|
|
7
|
-
def initialize(
|
7
|
+
def initialize(socket_path)
|
8
8
|
# Connect to Notepadqq socket
|
9
|
-
@client = UNIXSocket.open(
|
9
|
+
@client = UNIXSocket.open(socket_path)
|
10
10
|
|
11
|
-
@
|
12
|
-
@
|
11
|
+
@incoming_buffer = "" # Incomplete json messages (as strings)
|
12
|
+
@parsed_buffer = [] # Unprocessed object messages
|
13
13
|
end
|
14
14
|
|
15
15
|
# Sends a JSON message to Notepadqq
|
16
|
-
def
|
17
|
-
|
16
|
+
def send_message(msg)
|
17
|
+
send_raw_message(JSON.generate(msg))
|
18
18
|
end
|
19
19
|
|
20
20
|
# Read incoming messages
|
21
|
-
def
|
21
|
+
def get_messages(block=true)
|
22
22
|
|
23
23
|
begin
|
24
|
-
if block and @
|
24
|
+
if block and @incoming_buffer.empty? and @parsed_buffer.empty?
|
25
25
|
read = @client.recv(1048576)
|
26
26
|
else
|
27
27
|
read = @client.recv_nonblock(1048576)
|
@@ -30,15 +30,15 @@ class NotepadqqApi
|
|
30
30
|
read = ""
|
31
31
|
end
|
32
32
|
|
33
|
-
@
|
34
|
-
messages = @
|
33
|
+
@incoming_buffer += read
|
34
|
+
messages = @incoming_buffer.split("\n")
|
35
35
|
|
36
|
-
if @
|
36
|
+
if @incoming_buffer.end_with? "\n"
|
37
37
|
# We only got complete messages: clear the buffer
|
38
|
-
@
|
38
|
+
@incoming_buffer.clear
|
39
39
|
else
|
40
40
|
# We need to store the incomplete message in the buffer
|
41
|
-
@
|
41
|
+
@incoming_buffer = messages.pop || ""
|
42
42
|
end
|
43
43
|
|
44
44
|
converted = []
|
@@ -51,29 +51,29 @@ class NotepadqqApi
|
|
51
51
|
end
|
52
52
|
end
|
53
53
|
|
54
|
-
retval = @
|
55
|
-
@
|
54
|
+
retval = @parsed_buffer + converted
|
55
|
+
@parsed_buffer = []
|
56
56
|
|
57
57
|
# Make sure that, when block=true, at least one message is received
|
58
58
|
if block and retval.empty?
|
59
|
-
retval +=
|
59
|
+
retval += get_messages(true)
|
60
60
|
end
|
61
61
|
|
62
62
|
return retval
|
63
63
|
end
|
64
64
|
|
65
65
|
# Get the next message of type "result".
|
66
|
-
# The other messages will still be returned by
|
67
|
-
def
|
66
|
+
# The other messages will still be returned by get_messages
|
67
|
+
def get_next_result_message
|
68
68
|
discarded = []
|
69
69
|
|
70
70
|
while true do
|
71
|
-
chunk = self.
|
71
|
+
chunk = self.get_messages
|
72
72
|
for i in 0...chunk.length
|
73
73
|
if chunk[i].has_key?("result")
|
74
74
|
discarded += chunk[0...i]
|
75
75
|
discarded += chunk[i+1..-1]
|
76
|
-
@
|
76
|
+
@parsed_buffer = discarded
|
77
77
|
return chunk[i]
|
78
78
|
end
|
79
79
|
end
|
@@ -86,7 +86,7 @@ class NotepadqqApi
|
|
86
86
|
private
|
87
87
|
|
88
88
|
# Sends a raw string message to Notepadqq
|
89
|
-
def
|
89
|
+
def send_raw_message(msg)
|
90
90
|
@client.send(msg, 0)
|
91
91
|
end
|
92
92
|
|
@@ -1,8 +1,8 @@
|
|
1
1
|
class NotepadqqApi
|
2
2
|
class MessageInterpreter
|
3
3
|
|
4
|
-
def initialize(
|
5
|
-
@
|
4
|
+
def initialize(message_channel)
|
5
|
+
@message_channel = message_channel
|
6
6
|
|
7
7
|
# Hash of event handlers, for example
|
8
8
|
# {
|
@@ -11,43 +11,46 @@ class NotepadqqApi
|
|
11
11
|
# },
|
12
12
|
# ...
|
13
13
|
# }
|
14
|
-
# Where 1 is an
|
15
|
-
@
|
14
|
+
# Where 1 is an object_id and "newWindow" is an event of that object
|
15
|
+
@event_handlers = {}
|
16
16
|
end
|
17
17
|
|
18
|
-
# Assign an event of a particular
|
19
|
-
def
|
18
|
+
# Assign an event of a particular object_id to a callback
|
19
|
+
def register_event_handler(object_id, event, callback)
|
20
20
|
event = event.to_sym
|
21
21
|
|
22
|
-
@
|
23
|
-
@
|
22
|
+
@event_handlers[object_id] ||= {}
|
23
|
+
@event_handlers[object_id][event] ||= []
|
24
24
|
|
25
|
-
@
|
25
|
+
@event_handlers[object_id][event].push(callback)
|
26
26
|
end
|
27
27
|
|
28
|
-
# Calls a method on the remote object
|
29
|
-
def
|
28
|
+
# Calls a method on the remote object object_id
|
29
|
+
def invoke_api(object_id, method, args)
|
30
30
|
message = {
|
31
|
-
:objectId =>
|
31
|
+
:objectId => object_id,
|
32
32
|
:method => method,
|
33
33
|
:args => args
|
34
34
|
}
|
35
35
|
|
36
|
-
@
|
37
|
-
reply = @
|
36
|
+
@message_channel.send_message(message)
|
37
|
+
reply = @message_channel.get_next_result_message
|
38
38
|
|
39
39
|
result = [reply["result"]]
|
40
|
-
|
40
|
+
convert_stubs!(result)
|
41
41
|
result = result[0]
|
42
42
|
|
43
|
-
|
43
|
+
if reply["err"] != MessageInterpreterError::ErrorCode::NONE
|
44
|
+
error = MessageInterpreterError.new(reply["err"])
|
45
|
+
raise error, error.description
|
46
|
+
end
|
44
47
|
|
45
48
|
return result
|
46
49
|
end
|
47
50
|
|
48
|
-
def
|
51
|
+
def process_message(message)
|
49
52
|
if message.has_key?("event")
|
50
|
-
|
53
|
+
process_event_message(message)
|
51
54
|
elsif message.has_key?("result")
|
52
55
|
# We shouldn't have received it here... ignore it
|
53
56
|
end
|
@@ -56,15 +59,15 @@ class NotepadqqApi
|
|
56
59
|
private
|
57
60
|
|
58
61
|
# Call the handlers connected to this event
|
59
|
-
def
|
62
|
+
def process_event_message(message)
|
60
63
|
event = message["event"].to_sym
|
61
|
-
|
64
|
+
object_id = message["objectId"]
|
62
65
|
|
63
|
-
if @
|
64
|
-
handlers = @
|
66
|
+
if @event_handlers[object_id] and @event_handlers[object_id][event]
|
67
|
+
handlers = @event_handlers[object_id][event]
|
65
68
|
|
66
69
|
args = message["args"]
|
67
|
-
|
70
|
+
convert_stubs!(args)
|
68
71
|
|
69
72
|
(handlers.length-1).downto(0).each { |i|
|
70
73
|
handlers[i].call(*args)
|
@@ -72,32 +75,32 @@ class NotepadqqApi
|
|
72
75
|
end
|
73
76
|
end
|
74
77
|
|
75
|
-
def
|
78
|
+
def convert_stubs!(data_array)
|
76
79
|
# FIXME Use a stack
|
77
80
|
|
78
|
-
|
81
|
+
data_array.map! { |value|
|
79
82
|
unless value.nil?
|
80
83
|
if value.kind_of?(Array)
|
81
|
-
|
84
|
+
convert_stubs!(value)
|
82
85
|
|
83
86
|
elsif value.kind_of?(Hash) and
|
84
87
|
value["$__nqq__stub_type"].kind_of?(String) and
|
85
88
|
value["id"].kind_of?(Fixnum)
|
86
89
|
|
87
|
-
|
90
|
+
stub_type = value["$__nqq__stub_type"]
|
88
91
|
begin
|
89
|
-
stub = Object::const_get(Stubs.name + "::" +
|
92
|
+
stub = Object::const_get(Stubs.name + "::" + stub_type)
|
90
93
|
stub.new(self, value["id"])
|
91
94
|
rescue
|
92
|
-
puts "Unknown stub: " +
|
95
|
+
puts "Unknown stub: " + stub_type
|
93
96
|
value
|
94
97
|
end
|
95
98
|
|
96
99
|
elsif value.kind_of?(Hash)
|
97
100
|
value.each do |key, data|
|
98
|
-
|
99
|
-
|
100
|
-
value[key] =
|
101
|
+
tmp_array = [data]
|
102
|
+
convert_stubs!(tmp_array)
|
103
|
+
value[key] = tmp_array[0]
|
101
104
|
end
|
102
105
|
|
103
106
|
value
|
@@ -110,4 +113,38 @@ class NotepadqqApi
|
|
110
113
|
end
|
111
114
|
|
112
115
|
end
|
116
|
+
|
117
|
+
class MessageInterpreterError < RuntimeError
|
118
|
+
|
119
|
+
module ErrorCode
|
120
|
+
NONE = 0
|
121
|
+
INVALID_REQUEST = 1
|
122
|
+
INVALID_ARGUMENT_NUMBER = 2
|
123
|
+
INVALID_ARGUMENT_TYPE = 3
|
124
|
+
OBJECT_DEALLOCATED = 4
|
125
|
+
OBJECT_NOT_FOUND = 5
|
126
|
+
METHOD_NOT_FOUND = 6
|
127
|
+
end
|
128
|
+
|
129
|
+
attr_reader :error_code
|
130
|
+
|
131
|
+
def initialize(error_code)
|
132
|
+
@error_code = error_code
|
133
|
+
end
|
134
|
+
|
135
|
+
def description
|
136
|
+
case @error_code
|
137
|
+
when ErrorCode::NONE then "None"
|
138
|
+
when ErrorCode::INVALID_REQUEST then "Invalid request"
|
139
|
+
when ErrorCode::INVALID_ARGUMENT_NUMBER then "Invalid argument number"
|
140
|
+
when ErrorCode::INVALID_ARGUMENT_TYPE then "Invalid argument type"
|
141
|
+
when ErrorCode::OBJECT_DEALLOCATED then "Object deallocated"
|
142
|
+
when ErrorCode::OBJECT_NOT_FOUND then "Object not found"
|
143
|
+
when ErrorCode::METHOD_NOT_FOUND then "Method not found"
|
144
|
+
else "Unknown error"
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
end
|
149
|
+
|
113
150
|
end
|
data/lib/notepadqq_api/stubs.rb
CHANGED
@@ -3,28 +3,28 @@ class NotepadqqApi
|
|
3
3
|
|
4
4
|
class Stub
|
5
5
|
|
6
|
-
def initialize(
|
7
|
-
@
|
6
|
+
def initialize(message_interpreter, id)
|
7
|
+
@message_interpreter = message_interpreter
|
8
8
|
@id = id
|
9
9
|
end
|
10
10
|
|
11
11
|
def on(event, &callback)
|
12
|
-
@
|
12
|
+
@message_interpreter.register_event_handler(@id, event, callback)
|
13
13
|
end
|
14
14
|
|
15
15
|
def method_missing(method, *args, &block)
|
16
|
-
return @
|
16
|
+
return @message_interpreter.invoke_api(@id, method, args)
|
17
17
|
end
|
18
18
|
|
19
19
|
def ==(other)
|
20
20
|
other.class <= Stub &&
|
21
21
|
id == other.id &&
|
22
|
-
|
22
|
+
message_interpreter == other.message_interpreter
|
23
23
|
end
|
24
24
|
|
25
25
|
protected
|
26
26
|
|
27
|
-
attr_reader :id, :
|
27
|
+
attr_reader :id, :message_interpreter
|
28
28
|
|
29
29
|
end
|
30
30
|
|
data/lib/notepadqq_api.rb
CHANGED
@@ -5,43 +5,46 @@ require 'notepadqq_api/stubs'
|
|
5
5
|
class NotepadqqApi
|
6
6
|
|
7
7
|
NQQ_STUB_ID = 1
|
8
|
+
private_constant :NQQ_STUB_ID
|
8
9
|
|
9
|
-
attr_reader :
|
10
|
+
attr_reader :extension_id
|
10
11
|
|
11
|
-
def initialize(
|
12
|
-
@
|
13
|
-
@
|
12
|
+
def initialize(socket_path = ARGV[0], extension_id = ARGV[1])
|
13
|
+
@socket_path = socket_path
|
14
|
+
@extension_id = extension_id
|
14
15
|
|
15
|
-
@
|
16
|
-
@
|
16
|
+
@message_channel = MessageChannel.new(@socket_path)
|
17
|
+
@message_interpreter = MessageInterpreter.new(@message_channel)
|
17
18
|
end
|
18
19
|
|
19
20
|
# Start reading messages and calling event handlers
|
20
|
-
def
|
21
|
+
def run_event_loop
|
21
22
|
yield
|
22
23
|
|
23
24
|
while true do
|
24
|
-
messages = @
|
25
|
+
messages = @message_channel.get_messages
|
25
26
|
messages.each do |msg|
|
26
|
-
@
|
27
|
+
@message_interpreter.process_message(msg)
|
27
28
|
end
|
28
29
|
end
|
29
|
-
|
30
30
|
end
|
31
31
|
|
32
|
+
# For compatibility
|
33
|
+
alias_method :runEventLoop, :run_event_loop
|
34
|
+
|
32
35
|
# Execute a block for every new window.
|
33
36
|
# This is preferable to the "newWindow" event of Notepadqq, because it could
|
34
37
|
# happen that the extension isn't ready soon enough to receive the
|
35
38
|
# "newWindow" event for the first Window. This method, instead, ensures that
|
36
39
|
# the passed block will be called once and only once for each current or
|
37
40
|
# future window.
|
38
|
-
def
|
39
|
-
|
41
|
+
def on_window_created(&callback)
|
42
|
+
captured_windows = []
|
40
43
|
|
41
44
|
# Invoke the callback for every currently open window
|
42
45
|
notepadqq.windows.each do |window|
|
43
|
-
unless
|
44
|
-
|
46
|
+
unless captured_windows.include? window
|
47
|
+
captured_windows.push window
|
45
48
|
callback.call(window)
|
46
49
|
end
|
47
50
|
end
|
@@ -51,16 +54,19 @@ class NotepadqqApi
|
|
51
54
|
# we might not be fast enough to receive this event: this is why
|
52
55
|
# we manually invoked the callback for every currently open window.
|
53
56
|
notepadqq.on(:newWindow) do |window|
|
54
|
-
unless
|
55
|
-
|
57
|
+
unless captured_windows.include? window
|
58
|
+
captured_windows.push window
|
56
59
|
callback.call(window)
|
57
60
|
end
|
58
61
|
end
|
59
62
|
end
|
60
63
|
|
64
|
+
# For compatibility
|
65
|
+
alias_method :onWindowCreated, :on_window_created
|
66
|
+
|
61
67
|
# Returns an instance of Notepadqq
|
62
68
|
def notepadqq
|
63
|
-
@nqq ||= Stubs::Notepadqq.new(@
|
69
|
+
@nqq ||= Stubs::Notepadqq.new(@message_interpreter, NQQ_STUB_ID);
|
64
70
|
return @nqq
|
65
71
|
end
|
66
72
|
|
@@ -1,20 +1,21 @@
|
|
1
|
-
require '
|
1
|
+
require 'minitest/autorun'
|
2
2
|
require 'notepadqq_api/message_channel'
|
3
3
|
require 'notepadqq_api/message_interpreter'
|
4
4
|
require 'notepadqq_api/stubs'
|
5
5
|
|
6
|
-
class
|
7
|
-
def initialize(
|
8
|
-
|
9
|
-
|
10
|
-
|
6
|
+
class MessageChannelStub
|
7
|
+
def initialize(*) end
|
8
|
+
|
9
|
+
NotepadqqApi::MessageChannel.instance_methods(false).each do |m|
|
10
|
+
define_method(m) { |*| }
|
11
|
+
end
|
11
12
|
end
|
12
13
|
|
13
|
-
class MessageInterpreterTest < Test
|
14
|
+
class MessageInterpreterTest < Minitest::Test
|
14
15
|
|
15
|
-
def
|
16
|
-
channel =
|
17
|
-
def channel.
|
16
|
+
def test_invoke_api_simple_return
|
17
|
+
channel = MessageChannelStub.new nil
|
18
|
+
def channel.get_next_result_message
|
18
19
|
{
|
19
20
|
'err' => 0,
|
20
21
|
'result' => {"$__nqq__stub_type" => 'Notepadqq', "id" => 7}
|
@@ -23,13 +24,13 @@ class MessageInterpreterTest < Test::Unit::TestCase
|
|
23
24
|
|
24
25
|
interpreter = NotepadqqApi::MessageInterpreter.new channel
|
25
26
|
|
26
|
-
retval = interpreter.
|
27
|
+
retval = interpreter.invoke_api(1, 'example', [])
|
27
28
|
assert_equal NotepadqqApi::Stubs::Notepadqq.new(interpreter, 7), retval
|
28
29
|
end
|
29
30
|
|
30
|
-
def
|
31
|
-
channel =
|
32
|
-
def channel.
|
31
|
+
def test_invoke_api_array_return
|
32
|
+
channel = MessageChannelStub.new nil
|
33
|
+
def channel.get_next_result_message
|
33
34
|
{
|
34
35
|
'err' => 0,
|
35
36
|
'result' => [{"$__nqq__stub_type" => 'Notepadqq', "id" => 7},
|
@@ -41,7 +42,7 @@ class MessageInterpreterTest < Test::Unit::TestCase
|
|
41
42
|
|
42
43
|
interpreter = NotepadqqApi::MessageInterpreter.new channel
|
43
44
|
|
44
|
-
retval = interpreter.
|
45
|
+
retval = interpreter.invoke_api(1, 'example', [])
|
45
46
|
assert_equal [NotepadqqApi::Stubs::Notepadqq.new(interpreter, 7),
|
46
47
|
42,
|
47
48
|
NotepadqqApi::Stubs::Editor.new(interpreter, 10)
|
data/test/test_stubs.rb
CHANGED
@@ -1,7 +1,7 @@
|
|
1
|
-
require '
|
1
|
+
require 'minitest/autorun'
|
2
2
|
require 'notepadqq_api/stubs'
|
3
3
|
|
4
|
-
class StubsTest < Test
|
4
|
+
class StubsTest < Minitest::Test
|
5
5
|
|
6
6
|
def test_comparison
|
7
7
|
|
@@ -21,30 +21,30 @@ class StubsTest < Test::Unit::TestCase
|
|
21
21
|
|
22
22
|
# Different id
|
23
23
|
|
24
|
-
|
24
|
+
refute_equal NotepadqqApi::Stubs::Notepadqq.new(nil, 1),
|
25
25
|
NotepadqqApi::Stubs::Stub.new(nil, 2)
|
26
26
|
|
27
|
-
|
27
|
+
refute_equal NotepadqqApi::Stubs::Stub.new(nil, 1),
|
28
28
|
NotepadqqApi::Stubs::Notepadqq.new(nil, 2)
|
29
29
|
|
30
|
-
|
30
|
+
refute_equal NotepadqqApi::Stubs::Stub.new(nil, 1),
|
31
31
|
NotepadqqApi::Stubs::Stub.new(nil, 2)
|
32
32
|
|
33
|
-
|
33
|
+
refute_equal NotepadqqApi::Stubs::Notepadqq.new(nil, 1),
|
34
34
|
NotepadqqApi::Stubs::Notepadqq.new(nil, 2)
|
35
35
|
|
36
36
|
# Different channel
|
37
37
|
|
38
|
-
|
38
|
+
refute_equal NotepadqqApi::Stubs::Notepadqq.new("test", 7),
|
39
39
|
NotepadqqApi::Stubs::Stub.new(nil, 7)
|
40
40
|
|
41
|
-
|
41
|
+
refute_equal NotepadqqApi::Stubs::Stub.new("test", 7),
|
42
42
|
NotepadqqApi::Stubs::Notepadqq.new(nil, 7)
|
43
43
|
|
44
|
-
|
44
|
+
refute_equal NotepadqqApi::Stubs::Stub.new("test", 7),
|
45
45
|
NotepadqqApi::Stubs::Stub.new(nil, 7)
|
46
46
|
|
47
|
-
|
47
|
+
refute_equal NotepadqqApi::Stubs::Notepadqq.new("test", 7),
|
48
48
|
NotepadqqApi::Stubs::Notepadqq.new(nil, 7)
|
49
49
|
|
50
50
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: notepadqq_api
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Daniele Di Sarli
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2015-
|
11
|
+
date: 2015-05-02 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: json
|
@@ -38,6 +38,20 @@ dependencies:
|
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '10.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: minitest
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '5.5'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '5.5'
|
41
55
|
description: Notepadqq API Layer for extensions
|
42
56
|
email: danieleds0@gmail.com
|
43
57
|
executables: []
|
@@ -62,7 +76,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
62
76
|
requirements:
|
63
77
|
- - ">="
|
64
78
|
- !ruby/object:Gem::Version
|
65
|
-
version: '0'
|
79
|
+
version: '2.0'
|
66
80
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
67
81
|
requirements:
|
68
82
|
- - ">="
|