expect-pty 0.2.0 → 0.3.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.
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: expect-pty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - expect-pty contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-09-13 00:00:00.000000000 Z
10
+ date: 2026-09-26 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: Automate interactive programs with exact and regexp matching, Ruby blocks,
13
13
  multi-session waits, logging and terminal interaction.
@@ -34,13 +34,15 @@ files:
34
34
  - expect-pty.gemspec
35
35
  - lib/expect.rb
36
36
  - lib/expect/configuration.rb
37
- - lib/expect/engine.rb
38
- - lib/expect/interconnect.rb
37
+ - lib/expect/interaction.rb
38
+ - lib/expect/matcher.rb
39
39
  - lib/expect/pattern.rb
40
40
  - lib/expect/pattern_list.rb
41
41
  - lib/expect/pty.rb
42
- - lib/expect/resources.rb
42
+ - lib/expect/relay.rb
43
+ - lib/expect/relay_writer.rb
43
44
  - lib/expect/result.rb
45
+ - lib/expect/session_resources.rb
44
46
  - lib/expect/version.rb
45
47
  - script/ci
46
48
  - script/release.rb
@@ -61,6 +63,7 @@ files:
61
63
  - test/matching_test.rb
62
64
  - test/multi_session_test.rb
63
65
  - test/process_test.rb
66
+ - test/relay_recovery_test.rb
64
67
  - test/release_test.rb
65
68
  - test/ruby_api_test.rb
66
69
  - test/script_logging_test.rb
@@ -91,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
91
94
  - !ruby/object:Gem::Version
92
95
  version: '0'
93
96
  requirements: []
94
- rubygems_version: 4.0.16
97
+ rubygems_version: 4.0.12
95
98
  specification_version: 4
96
99
  summary: Ruby PTY automation with the Expect.pm interaction model
97
100
  test_files: []
@@ -1,170 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # 为会话补充人工接管和多路 IO 转接;核心会话定义位于 lib/expect.rb。
4
- class Expect
5
- # 注册字面、正则转义或 :eof 事件;回调用闭包保存上下文,nil/false 停止,其余值继续。
6
- def on_sequence(sequence, &block)
7
- key = case sequence
8
- when :eof, Regexp then sequence
9
- when String then sequence.b.freeze
10
- else raise ArgumentError, "sequence must be a String, Regexp or :eof"
11
- end
12
- raise ArgumentError, "escape sequence must not be empty" if key == ""
13
-
14
- @sequences[key] = block
15
- self
16
- end
17
-
18
- # 临时将输入、会话和输出相连,实现人工接管;结束时恢复双方监听器、日志开关和转义设置。
19
- def interact(input: $stdin, escape: nil, output: nil, timeout: nil)
20
- source = input.is_a?(Expect) ? input : Expect.open(input)
21
- output ||= input.equal?($stdin) ? $stdout : input
22
- saved_self = [listeners, log_stdout, log_listeners]
23
- saved_source = [source.listeners, source.log_stdout, source.log_listeners, source.sequences.dup]
24
- # 临时建立“用户输入 -> 子进程 -> 显示输出”的双向连接,原监听关系在 ensure 中恢复。
25
- self.listeners = [output]
26
- self.log_stdout = false
27
- self.log_listeners = true
28
- source.listeners = [self]
29
- source.log_stdout = false
30
- source.log_listeners = true
31
- source.on_sequence(escape) if escape
32
- Expect.interconnect(self, source, timeout: timeout)
33
- ensure
34
- if saved_self
35
- self.listeners, self.log_stdout, self.log_listeners = saved_self
36
- source.listeners, source.log_stdout, source.log_listeners, source.sequences = saved_source
37
- end
38
- end
39
-
40
- # 按各会话 listeners 建立转发图,处理转义、EOF 和总期限,返回引发停止的会话或 nil。
41
- def self.interconnect(*sessions, timeout: nil)
42
- raise ArgumentError, "interconnect requires Expect sessions" if sessions.empty? || sessions.any? do |session|
43
- !session.is_a?(Expect)
44
- end
45
-
46
- period = duration(timeout)
47
- deadline = period && (monotonic + period)
48
- active = sessions.uniq
49
- # buffers 保存尚未转发的字节,histories 保存正则跨读取所需的已转发前缀。
50
- # 接管原匹配缓冲时只转发,日志已在实际读取时记录,不能重复写入。
51
- buffers = active.to_h { |session| [session, session.clear_buffer] }
52
- histories = active.to_h { |session| [session, "".b] }
53
- terminal_objects = active.flat_map { |session| [session, *session.listeners] }.uniq
54
- saved = []
55
- polled = false
56
- begin
57
- terminal_objects.each do |object|
58
- next if object.is_a?(Expect) && !object.raw_terminal?
59
-
60
- io = object.respond_to?(:to_io) ? object.to_io : object
61
- next unless io.respond_to?(:tty?) && io.tty?
62
- next if saved.any? { |entry| entry.first.equal?(io) }
63
-
64
- # 修改前先保存模式,同一个 IO 对象只保存一次,部分初始化失败也能恢复。
65
- saved << [io, io.console_mode]
66
- io.raw!
67
- end
68
-
69
- loop do
70
- active.dup.each do |session|
71
- return session unless relay_buffer(session, buffers, histories, final: session.eof?)
72
- next unless session.eof?
73
-
74
- # EOF 回调为真才继续监听其他源,当前结束源随后移出 active。
75
- callback = session.__send__(:sequences)[:eof]
76
- return session unless callback&.call
77
-
78
- active.delete(session)
79
- end
80
- return nil if active.empty?
81
-
82
- remaining = deadline && [deadline - monotonic, 0].max
83
- ready = polled && remaining&.zero? ? nil : IO.select(active.map(&:to_io), nil, nil, remaining)
84
- polled = true
85
- unless ready
86
- # 到期后不会再等转义的后半段,将暂存的字面前缀作为普通输入转发。
87
- buffers.each do |session, buffer|
88
- session.__send__(:propagate, buffer) unless buffer.empty?
89
- buffer.clear
90
- end
91
- return nil
92
- end
93
- ready[0].each do |io|
94
- session = active.find { |candidate| candidate.to_io.equal?(io) }
95
- data = session.__send__(:read_available, propagate: false, accumulate: false)
96
- buffers[session] << data if data
97
- end
98
- end
99
- ensure
100
- # 转义后的尾部归下次 expect/interact 使用;无论正常返回还是异常,都归还缓冲并恢复终端。
101
- buffers.each { |session, buffer| session.buffer = buffer + session.buffer }
102
- saved.reverse_each { |io, mode| io.console_mode = mode unless io.closed? }
103
- end
104
- end
105
-
106
- # 转发一个会话的待处理缓冲并剔除转义;返回 false 表示应结束整个转接。
107
- # 字面序列暂存潜在前缀,正则序列结合历史匹配;final 为真时不再等待后续字节。
108
- def self.relay_buffer(session, buffers, histories, final: false)
109
- buffer = buffers.fetch(session)
110
- history = histories.fetch(session)
111
- sequences = session.__send__(:sequences).except(:eof)
112
- loop do
113
- matches = sequences.filter_map do |key, handler|
114
- if key.is_a?(Regexp)
115
- position = Pattern.new(value: key).locate(history + buffer)
116
- if position
117
- offset, length, = position
118
- raise ArgumentError, "escape regexp must consume at least one byte" if length.zero?
119
-
120
- # 历史前缀已实时转发,不能撤回;负偏移表示本次只需消费转义尚未转发的部分。
121
- [offset - history.bytesize, length, handler]
122
- end
123
- else
124
- position = buffer.index(key)
125
- [position, key.bytesize, handler] if position
126
- end
127
- end
128
- if (found = matches.min_by(&:first))
129
- position, length, callback = found
130
- session.__send__(:propagate, buffer.byteslice(0, position)) if position.positive?
131
- buffer.replace(buffer.byteslice([position + length, 0].max..))
132
- # 转义消费后清除历史,防止继续回调再次匹配同一个转义。
133
- history.clear
134
- return false unless callback&.call
135
-
136
- next
137
- end
138
-
139
- # 暂存可能构成字面转义的最长后缀,保证 STOP 分两次读取时 ST 不会提前发给子进程。
140
- held = 0
141
- unless final
142
- sequences.each_key do |key|
143
- next if key.is_a?(Regexp)
144
-
145
- [key.bytesize - 1, buffer.bytesize].min.downto(1) do |length|
146
- if buffer.end_with?(key.byteslice(0, length))
147
- held = [held, length].max
148
- break
149
- end
150
- end
151
- end
152
- end
153
- count = buffer.bytesize - held
154
- session.__send__(:propagate, buffer.byteslice(0, count)) if count.positive?
155
- if sequences.keys.any?(Regexp)
156
- history << buffer.byteslice(0, count)
157
- limit = session.buffer_limit
158
- history.replace(history.byteslice(-limit, limit)) if limit && history.bytesize > limit
159
- end
160
- buffer.replace(held.zero? ? "".b : buffer.byteslice(-held, held))
161
- return true
162
- end
163
- end
164
- private_class_method :relay_buffer
165
-
166
- protected
167
-
168
- # 仅供转接内部保存和恢复注册表,避免公开可变 Hash 绕过 on_sequence 的校验。
169
- attr_accessor :sequences
170
- end