expect-pty 0.7.7 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +1 -0
- data/README.zh-CN.md +6 -0
- data/docs/API.md +44 -0
- data/lib/expect/cleanup.rb +10 -3
- data/lib/expect/interaction.rb +1 -1
- data/lib/expect/literal_prefix.rb +13 -2
- data/lib/expect/logging.rb +1 -1
- data/lib/expect/matcher.rb +14 -6
- data/lib/expect/pattern.rb +3 -2
- data/lib/expect/pattern_list.rb +1 -1
- data/lib/expect/redactor.rb +3 -1
- data/lib/expect/{session_resources.rb → resource_owner.rb} +6 -6
- data/lib/expect/responder.rb +92 -0
- data/lib/expect/session.rb +22 -12
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +6 -4
- data/sig/expect.rbs +16 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ee45b8c572aa37af57de1fae7b298e0a73a1b1157bb77949edb3918b100b8a81
|
|
4
|
+
data.tar.gz: 6767f2e994b45d693e5701a9f153cc3991c48255c3bd6f0fec8ce5884aa72fb2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b2e908dc05179ec58c9cfa11d5105f9e1b1b4089996a588ae2f14dd93b5218e0478ad43e749320200b98d880da54d0381963f7dc59891e943f0d1092bf4b4783
|
|
7
|
+
data.tar.gz: ae06bd3ffdac6991bf510d10bf52665bfeaaaac6ea46a78b1e7fdc56f37fb6b4523c29e2a3519f6a374dd675df7cc94607c1b56923cf25bff89603bd95549362
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.8.0 - 2026-10-11
|
|
6
|
+
|
|
7
|
+
- 新增可复用的自动应答规则,支持重复确认、分页按键、动态回复和次数上限,按输入顺序处理提示且不延长等待期限,并提供批量脚本示例。
|
|
8
|
+
- 修复直接软关闭在读尾、日志交付失败或取消时跳过资源清理的问题,保留原始异常和不发送 KILL 的约定,失败时跳过自然退出等待。
|
|
9
|
+
- 减少结果快照、继续匹配、单对象写入和正则转义的临时分配,保留不可变结果、捕获、编码校验及部分写入进度。
|
|
10
|
+
|
|
11
|
+
## 0.7.8 - 2026-10-11
|
|
12
|
+
|
|
13
|
+
- 将内部资源所有权对象命名为 ResourceOwner,并统一源码、示例和测试辅助代码的中文注释。
|
|
14
|
+
- 补齐字节前缀算法、脱敏窗口及基准、API 校验和示例辅助方法的契约注释,明确缓存、输入和副作用边界。
|
|
15
|
+
|
|
5
16
|
## 0.7.7 - 2026-10-11
|
|
6
17
|
|
|
7
18
|
- 修复人工接管时终端模式交接中断、恢复异常覆盖业务主异常的问题;分别尝试恢复两侧输出、转义规则和终端模式。
|
data/README.md
CHANGED
|
@@ -38,6 +38,7 @@ The block closes the session and reaps its child process, including when the blo
|
|
|
38
38
|
|
|
39
39
|
## Features
|
|
40
40
|
|
|
41
|
+
- Reusable automatic response rules for repeated confirmations, paging, and dynamic replies.
|
|
41
42
|
- Exact byte-string and Ruby Regexp matching, immutable results, callbacks, timeouts, and EOF handling.
|
|
42
43
|
- Explicit PTY sessions or borrowed IO sessions, with scoped cleanup and process status.
|
|
43
44
|
- Multi-session waits, IO forwarding, and interactive terminal handoff.
|
data/README.zh-CN.md
CHANGED
|
@@ -43,6 +43,12 @@ end
|
|
|
43
43
|
- 多会话等待、IO 转发和人工终端接管。
|
|
44
44
|
- 可选的 Logger 诊断、接收记录和流式脱敏。
|
|
45
45
|
|
|
46
|
+
## 批量脚本自动应答
|
|
47
|
+
|
|
48
|
+
`Expect::Responder` 支持复用自定义规则、重复确认、分页按键和正则捕获动态回复。
|
|
49
|
+
每条命令按输入位置处理提示,规则可设置应答次数上限,应答不会延长等待期限。
|
|
50
|
+
详见 [API 自动应答](docs/API.md#自动应答),可运行 `ruby examples/auto_response.rb` 验证本地设备模拟。
|
|
51
|
+
|
|
46
52
|
## 文档
|
|
47
53
|
|
|
48
54
|
- [API 参考](docs/API.md)
|
data/docs/API.md
CHANGED
|
@@ -22,6 +22,8 @@ Session 构造器不接收命令或操作参数。同一会话只能启动一次
|
|
|
22
22
|
TERM;
|
|
23
23
|
`hard_close(timeout: 0.2)` 关闭句柄后分阶段等待、TERM、KILL。后两者和 `wait(timeout: nil)` 返回真实 `Process::Status` 或
|
|
24
24
|
nil。
|
|
25
|
+
直接 `soft_close` 在读尾、日志或输出交付失败时仍尝试软关闭并保留原始异常;失败路径跳过剩余自然退出等待,
|
|
26
|
+
尤其不会因 `timeout: nil` 阻塞异常传播,TERM 阶段仍使用 `term_timeout`,不会升级为 KILL。参数校验失败不关闭会话。
|
|
25
27
|
借用 IO 不随会话关闭。输入 EOF、IO 关闭与子进程退出分别由 `eof?`、`closed?`、`process_status` 表示。
|
|
26
28
|
|
|
27
29
|
## 匹配与回调
|
|
@@ -63,6 +65,48 @@ nil。
|
|
|
63
65
|
EncodingError。
|
|
64
66
|
成功默认消费匹配前缀,超时保留缓冲,EOF 将余下内容放入 before 并清空缓冲。EOF 不表示子进程已经退出。
|
|
65
67
|
|
|
68
|
+
## 自动应答
|
|
69
|
+
|
|
70
|
+
已有 `PatternList#on` 配合 `Expect.continue` 可手写应答循环。批量脚本使用 `Expect::Responder` 复用规则,
|
|
71
|
+
不必每条命令重新拼接回调;普通 `expect` 的声明优先级保持不变。
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
rules = Expect::Responder.new do |responses|
|
|
75
|
+
responses.on("Apply change? [y/N] ", reply: "y\n", limit: 5)
|
|
76
|
+
responses.on("--More--", reply: " ", limit: 100)
|
|
77
|
+
responses.on(/Repeat code (\d+): /, limit: 1) { |session| session.puts(session.captures.first) }
|
|
78
|
+
end.freeze
|
|
79
|
+
|
|
80
|
+
# 登录并收取设备初始提示后,再逐条发送配置。
|
|
81
|
+
commands.each do |command|
|
|
82
|
+
session.puts(command)
|
|
83
|
+
result = rules.expect(session, "device>", timeout: 30, deadline: Expect.monotonic + 60)
|
|
84
|
+
raise "command incomplete: #{result.error}" unless result.matched?
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- `on(pattern, reply: nil, limit: nil)` 只接受非空 String 或 Regexp;必须提供 String 回复或动作块,二者互斥。
|
|
89
|
+
静态字符串复制并冻结,块收到带 `before`、`match`、`captures` 的 Session;块返回值忽略,应答后继续等待。
|
|
90
|
+
- `reply` 原样发送,行确认自行包含 `\n` 或 `\r`,分页可发送空格;空回复合法但不产生字节。
|
|
91
|
+
不内置通用“全部确认”规则,调用方应按目标设备及业务选择精确提示和回复。
|
|
92
|
+
- `expect(session, *patterns, timeout: session.timeout, deadline: nil)` 仅处理一个 Session;也可使用与 `expect` 相同的
|
|
93
|
+
PatternList 块声明完成模式、EOF 和 timeout 回调。位置参数模式与块不能同时提供。
|
|
94
|
+
- 自动应答与完成模式按最早的字节位置处理;同位置时完成模式先于应答,多个应答按声明顺序。
|
|
95
|
+
不按正则长度推测未完成提示,不在未知提示上猜测回复。仅将成功匹配的文本视为完成,业务错误输出需由调用方注册为终止模式并检查。
|
|
96
|
+
- 每条规则的 `limit` 是一次等待内的最大应答次数,必须为正 Integer,nil 不限;每次调用独立计数。
|
|
97
|
+
超限或正则匹配空文本时,抛出 `Expect::Responder::ResponseError`,且不执行本次应答。触发该异常的匹配已经按普通匹配语义消费,
|
|
98
|
+
未读尾部留在会话中。应答发送失败原样传播,不自动重试可能已经部分交付的确认。
|
|
99
|
+
- 应答不重置相对 timeout,deadline 仍是不可延长的总期限;沿用 Matcher 的协作式期限检查。
|
|
100
|
+
动作块、单次正则计算和写入不由匹配期限强行中断;应为会话配置 `write_timeout`。
|
|
101
|
+
timeout 和 EOF 返回普通 Result,默认未知提示等到超时。显式完成回调仍遵循 `Expect.continue` 契约。
|
|
102
|
+
- 中途应答消费 `before` 和 `match`;最后 Result 仅表示完成、EOF 或超时,不汇总整条脚本输出。
|
|
103
|
+
需要全文时在发送命令前设置 `session.transcript`;逐段展示可在动作块打印 `before/match`,完成后打印最终 `before`。
|
|
104
|
+
完成后的尾部保留给下一轮。发送秘密前调用 `session.redact(secret)`,回复会经过正常写入诊断,规则摘要不展示内容。
|
|
105
|
+
- 注册阶段不读写 IO;每次等待快照规则,回调中的规则修改仅影响以后等待。不接管会话资源,
|
|
106
|
+
不保证同一会话并发操作安全;设备的 SSH 登录和配置成功判定仍属于调用方。
|
|
107
|
+
|
|
108
|
+
本地设备模拟见 `examples/auto_response.rb`;`examples/ssh_auto.rb` 接入精确规则并保留分段输出和完整 transcript。
|
|
109
|
+
|
|
66
110
|
## 会话属性与读写
|
|
67
111
|
|
|
68
112
|
不提供全局配置或配置对象。构造器、工厂显式接受以下设置,会话可通过同名 reader/writer 修改;非法更新保留原值。
|
data/lib/expect/cleanup.rb
CHANGED
|
@@ -6,14 +6,21 @@ module Expect
|
|
|
6
6
|
module Cleanup
|
|
7
7
|
# 包括非局部返回在内的所有退出均清理;StandardError 只在没有原异常时传播。
|
|
8
8
|
# 清理期间新发生的 Interrupt、SystemExit 等致命异常仍原样传播。
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
# report_completion 仅供关闭策略区分正常结束、取消及非局部退出,不改变异常优先级。
|
|
10
|
+
def self.always(on_exit, report_completion: false)
|
|
11
|
+
result = yield
|
|
12
|
+
completed = true
|
|
13
|
+
result
|
|
11
14
|
rescue Exception # rubocop:disable Lint/RescueException -- 中断也必须清理,然后原样传播。
|
|
12
15
|
failed = true
|
|
13
16
|
raise
|
|
14
17
|
ensure
|
|
15
18
|
begin
|
|
16
|
-
|
|
19
|
+
if report_completion
|
|
20
|
+
on_exit.call(completed && !failed && Thread.current.status != "aborting")
|
|
21
|
+
else
|
|
22
|
+
on_exit.call
|
|
23
|
+
end
|
|
17
24
|
rescue StandardError
|
|
18
25
|
raise unless failed
|
|
19
26
|
end
|
data/lib/expect/interaction.rb
CHANGED
|
@@ -142,7 +142,7 @@ module Expect
|
|
|
142
142
|
# 仅在本轮扫描复用组合文本;回调可能更换规则或消费缓冲,下一轮必须重新构造。
|
|
143
143
|
text ||= history + buffer
|
|
144
144
|
utf8_regexp ||= key.fixed_encoding? && key.encoding == Encoding::UTF_8
|
|
145
|
-
position = Pattern.new(value: key).locate(text, final:)
|
|
145
|
+
position = Pattern.new(value: key).locate(text, final:, captures: false)
|
|
146
146
|
if position
|
|
147
147
|
offset, length, = position
|
|
148
148
|
raise ArgumentError, "escape regexp must consume at least one byte" if length.zero?
|
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module Expect
|
|
4
4
|
# 计算字节窗口尾部与规则真前缀重合的最长长度;转义暂存和流尾脱敏使用同一边界判断。
|
|
5
|
-
#
|
|
5
|
+
# 不持有输入或修改缓冲;仅长候选的扫描需要 KMP 回退表,冻结规则可跨调用缓存该表。
|
|
6
6
|
# @api private
|
|
7
7
|
module LiteralPrefix
|
|
8
|
+
# 返回 buffer 后缀与 pattern 真前缀重合的最大字节数;真前缀不包含完整 pattern。
|
|
9
|
+
# 例如 "xAB" 与 "ABC" 返回 2;空输入、长度不足 2 的规则或没有重合时返回 0。
|
|
10
|
+
# 两个字符串须由调用方转为二进制编码;此处不转码、不校验,也不修改它们。
|
|
11
|
+
# prefix_tables 为可选的可变 Hash;仅冻结 pattern 使用缓存,否则为本次调用建立临时表。
|
|
8
12
|
def self.length(buffer, pattern, prefix_tables = nil)
|
|
9
13
|
maximum = [pattern.bytesize - 1, buffer.bytesize].min
|
|
10
14
|
return 0 unless maximum.positive?
|
|
11
15
|
|
|
12
|
-
#
|
|
16
|
+
# 重合前缀的末字节必须等于输入尾字节;排除无法结束在该字节上的更长候选。
|
|
17
|
+
# 最长候选直接命中时不必构造回退表;小于 64 字节的候选改用有界枚举。
|
|
13
18
|
ending = pattern.rindex(buffer.byteslice(-1, 1), maximum - 1)
|
|
14
19
|
return 0 unless ending
|
|
15
20
|
|
|
@@ -19,6 +24,7 @@ module Expect
|
|
|
19
24
|
|
|
20
25
|
failure = prefix_tables && pattern.frozen? ? (prefix_tables[pattern] ||= []) : []
|
|
21
26
|
extend_failure_table(pattern, failure, maximum)
|
|
27
|
+
# 只扫描可能重合的尾部;不匹配时沿回退表保留已有重合,避免从每个候选起点重扫。
|
|
22
28
|
matched = 0
|
|
23
29
|
index = buffer.bytesize - maximum
|
|
24
30
|
while index < buffer.bytesize
|
|
@@ -30,6 +36,9 @@ module Expect
|
|
|
30
36
|
matched
|
|
31
37
|
end
|
|
32
38
|
|
|
39
|
+
# 将同一规则的可变回退表补齐到 length 项,已有项不重算,较长的缓存也不截断。
|
|
40
|
+
# failure[i] 是 pattern[0..i] 的最长真前缀与后缀重合长度,供失配时选择下一匹配状态。
|
|
41
|
+
# length 不超过规则的真前缀长度;表只能用于生成它的同一规则内容。
|
|
33
42
|
def self.extend_failure_table(pattern, failure, length)
|
|
34
43
|
failure << 0 if failure.empty?
|
|
35
44
|
matched = failure.last
|
|
@@ -49,6 +58,8 @@ module Expect
|
|
|
49
58
|
end
|
|
50
59
|
end
|
|
51
60
|
|
|
61
|
+
# 从 maximum 向下枚举短候选,返回首个后缀重合长度,无命中时返回 0。
|
|
62
|
+
# 调用方已限制候选规模;每次比较只切取规则前缀,不创建完整输入的副本或回退表。
|
|
52
63
|
def self.short_length(buffer, pattern, maximum)
|
|
53
64
|
maximum.downto(1) do |length|
|
|
54
65
|
return length if buffer.end_with?(pattern.byteslice(0, length))
|
data/lib/expect/logging.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Expect
|
|
|
10
10
|
# 宿主 Session 初始化目标与过滤器状态,并提供 pid、fileno 作为诊断元数据。
|
|
11
11
|
# 三种输出各有边界:Logger 接收事件,transcript 接收脱敏字节,outputs 接收原协议字节。
|
|
12
12
|
module Logging
|
|
13
|
-
# 调用方提供的诊断 Logger
|
|
13
|
+
# 调用方提供的诊断 Logger 与接收记录 writer,均由调用方管理生命周期。
|
|
14
14
|
attr_reader :logger, :transcript
|
|
15
15
|
|
|
16
16
|
# Logger 自己决定级别、格式与失败策略;nil 禁用诊断,不隐式写入 stderr。
|
data/lib/expect/matcher.rb
CHANGED
|
@@ -6,11 +6,13 @@ module Expect
|
|
|
6
6
|
# @api private
|
|
7
7
|
class Matcher
|
|
8
8
|
# 固定本次参与的会话及初始期限;已处理 EOF 的会话仅从本次等待中移除。
|
|
9
|
-
def initialize(patterns, timeout, deadline: nil, consume: true, reset_timeout_on_read: false
|
|
9
|
+
def initialize(patterns, timeout, deadline: nil, consume: true, reset_timeout_on_read: false,
|
|
10
|
+
match_by_position: false)
|
|
10
11
|
@patterns = patterns.finalize!
|
|
11
12
|
@sessions = patterns.sessions
|
|
12
13
|
@groups = patterns.text_groups
|
|
13
14
|
@consume = consume
|
|
15
|
+
@match_by_position = match_by_position
|
|
14
16
|
@reset_timeout_on_read = reset_timeout_on_read
|
|
15
17
|
@timeout = Expect.duration(timeout)
|
|
16
18
|
# 相对期限可因接收或 continue 重算,总期限始终固定;两者共用单调时钟。
|
|
@@ -73,12 +75,13 @@ module Expect
|
|
|
73
75
|
end
|
|
74
76
|
end
|
|
75
77
|
|
|
76
|
-
#
|
|
78
|
+
# 普通等待按声明组、会话、模式选择;单会话自动应答选最早字节位置,同位置保留声明顺序。
|
|
77
79
|
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 声明优先级与单轮缓冲须在同一次扫描保持一致。
|
|
78
80
|
def find_match
|
|
79
81
|
# 编译后的组内来源已按身份去重;借用仅活在本次无回调扫描,不跨越 IO 或用户回调。
|
|
80
82
|
groups = @groups
|
|
81
83
|
buffers = {}.compare_by_identity if groups.size > 1
|
|
84
|
+
earliest = nil
|
|
82
85
|
groups.each do |sessions, patterns|
|
|
83
86
|
sessions.each do |session|
|
|
84
87
|
next if @handled_eof.key?(session)
|
|
@@ -100,11 +103,15 @@ module Expect
|
|
|
100
103
|
end
|
|
101
104
|
# 正则本身不可由 IO 期限中断;恢复控制后也不能消费已过总期限的匹配。
|
|
102
105
|
return nil if hard_expired?
|
|
103
|
-
|
|
106
|
+
next unless position
|
|
107
|
+
|
|
108
|
+
return [session, pattern, position] unless @match_by_position
|
|
109
|
+
|
|
110
|
+
earliest = [session, pattern, position] if !earliest || position.first < earliest.last.first
|
|
104
111
|
end
|
|
105
112
|
end
|
|
106
113
|
end
|
|
107
|
-
|
|
114
|
+
earliest
|
|
108
115
|
end
|
|
109
116
|
|
|
110
117
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
@@ -137,14 +144,15 @@ module Expect
|
|
|
137
144
|
|
|
138
145
|
# 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
|
|
139
146
|
def handle_match(session, pattern, position)
|
|
140
|
-
|
|
147
|
+
# 没有回调的终止匹配不可能继续,无须复制完整缓冲建立防空转快照。
|
|
148
|
+
previous_buffer = session.buffer if pattern.callback
|
|
141
149
|
received_bytes = session.received_bytes
|
|
142
150
|
result = session.record_match(pattern, position, consume: @consume)
|
|
143
151
|
action = pattern.call(session)
|
|
144
152
|
return result unless continuing?(action)
|
|
145
153
|
|
|
146
154
|
# 同值赋回仍须防止空转;嵌套读取到的新字节即使内容相同,也能恢复匹配。
|
|
147
|
-
if session.received_bytes == received_bytes && session.
|
|
155
|
+
if session.received_bytes == received_bytes && session.scan_buffer == previous_buffer
|
|
148
156
|
stalled = (@stalled_matches[session] ||= { buffer: previous_buffer, received_bytes:, patterns: [] })
|
|
149
157
|
stalled[:patterns] << pattern
|
|
150
158
|
end
|
data/lib/expect/pattern.rb
CHANGED
|
@@ -19,8 +19,9 @@ module Expect
|
|
|
19
19
|
|
|
20
20
|
# 在缓冲中定位字符串或正则,返回 [字节偏移, 字节长度, 捕获组];事件或未匹配返回 nil。
|
|
21
21
|
# buffer 应为二进制字符串,offset 仅用于字面扫描;正则始终看到完整窗口以保留锚点语义。
|
|
22
|
+
# captures 为 false 时只求字节范围,转义扫描不分配随后丢弃的捕获字符串。
|
|
22
23
|
# final 表示不会再有新输入,此时不完整的编码尾部也必须报错,不能永远当作等待分片。
|
|
23
|
-
def locate(buffer, final: false, offset: 0)
|
|
24
|
+
def locate(buffer, final: false, offset: 0, captures: true)
|
|
24
25
|
case value
|
|
25
26
|
when String
|
|
26
27
|
offset = buffer.index(value, offset)
|
|
@@ -42,7 +43,7 @@ module Expect
|
|
|
42
43
|
|
|
43
44
|
# 直接使用正则的字节范围,避免为转换字符偏移创建前缀切片;捕获组也返回字节串。
|
|
44
45
|
offset, finish = found.byteoffset(0)
|
|
45
|
-
return [offset, finish - offset, found.captures.map { |capture| capture&.b }]
|
|
46
|
+
return [offset, finish - offset, captures ? found.captures.map { |capture| capture&.b } : []]
|
|
46
47
|
end
|
|
47
48
|
nil
|
|
48
49
|
end
|
data/lib/expect/pattern_list.rb
CHANGED
data/lib/expect/redactor.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Expect
|
|
|
13
13
|
filter.append(data) + filter.finish(partial: false)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
# pending 保存尚不能安全输出的原字节,hidden
|
|
16
|
+
# 复制冻结替换文本与规则;pending 保存尚不能安全输出的原字节,hidden 用 0/1 标记遮盖。
|
|
17
17
|
def initialize(patterns, replacement: "[FILTERED]")
|
|
18
18
|
unless replacement.is_a?(String) && !replacement.empty?
|
|
19
19
|
raise ArgumentError, "replacement must be a nonempty String"
|
|
@@ -67,6 +67,7 @@ module Expect
|
|
|
67
67
|
private
|
|
68
68
|
|
|
69
69
|
# 输出资格与秘密掩码分开保存;仅遇到被抑制的分片时才分配额外字节图。
|
|
70
|
+
# 三个字节窗口始终对齐;先标记完整命中,再释放最长规则长度减一之外的安全前缀。
|
|
70
71
|
def append_chunk(data, visible:)
|
|
71
72
|
raise ArgumentError, "data must be a String" unless data.is_a?(String)
|
|
72
73
|
|
|
@@ -147,6 +148,7 @@ module Expect
|
|
|
147
148
|
end
|
|
148
149
|
|
|
149
150
|
# 仅级别切换后的流需要按输出资格再分段,普通过滤不承担额外区间调用。
|
|
151
|
+
# 将指定掩码区间中可见的字节追加到 output;被抑制的明文会断开 masking,隐藏字节不会。
|
|
150
152
|
def release_suppressed(output, starting, ending, hidden:)
|
|
151
153
|
while starting < ending
|
|
152
154
|
suppressed = @suppressed.getbyte(starting) == 1
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Expect
|
|
4
|
-
#
|
|
5
|
-
#
|
|
4
|
+
# 管理 IO 关闭责任和直属子进程归属,供显式关闭与 GC 回收共用。
|
|
5
|
+
# 不直接引用 Session、日志目标或用户回调;句柄关闭与进程退出分别记录。
|
|
6
6
|
# @api private
|
|
7
|
-
class
|
|
8
|
-
#
|
|
7
|
+
class ResourceOwner
|
|
8
|
+
# 缓存已取得的 Process::Status;被外部回收的进程仍保留未知状态,不伪造成功。
|
|
9
9
|
attr_accessor :status
|
|
10
10
|
# own 控制句柄关闭责任,owner 控制进程回收责任;fork 后两者不能混为一谈。
|
|
11
11
|
attr_reader :pid, :reader, :writer, :slave, :owner, :own
|
|
@@ -60,7 +60,7 @@ module Expect
|
|
|
60
60
|
status
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
# GC
|
|
63
|
+
# GC 兜底关闭所属句柄并终止尚存活的子进程;不调用会话日志或转义回调,也不进行软关闭等待。
|
|
64
64
|
# 可直接用绑定到资源对象的 Method 注册终结器,忽略 Ruby 传来的被回收对象 ID。
|
|
65
65
|
def finalize(_object_id = nil)
|
|
66
66
|
return unless owner == Process.pid
|
|
@@ -77,7 +77,7 @@ module Expect
|
|
|
77
77
|
|
|
78
78
|
private
|
|
79
79
|
|
|
80
|
-
# GC 只做一次非阻塞回收和最多两次信号尝试;失败也把等待交给 detach
|
|
80
|
+
# GC 只做一次非阻塞回收和最多两次信号尝试;失败也把等待交给 detach,不调用会话日志或转义回调。
|
|
81
81
|
def finalize_child
|
|
82
82
|
begin
|
|
83
83
|
reap
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Expect
|
|
4
|
+
# 可复用的自动应答规则;声明不持有会话,每次等待独立统计应答次数。
|
|
5
|
+
# 输入按字节位置处理,同位置的完成模式优先,其余按规则声明顺序。
|
|
6
|
+
class Responder
|
|
7
|
+
# 规则超限或匹配空文本时停止自动发送,避免反复确认和零宽应答循环。
|
|
8
|
+
class ResponseError < StandardError; end
|
|
9
|
+
|
|
10
|
+
# 建立规则集;块保留调用方 self,通过参数注册规则。
|
|
11
|
+
def initialize
|
|
12
|
+
@rules = []
|
|
13
|
+
yield self if block_given?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# 注册字面或正则提示;reply 原样写入,换行须显式包含,也可用块操作匹配会话。
|
|
17
|
+
# limit 是本规则在一次 expect 中允许的应答次数,nil 不限;规则文本和静态回复复制冻结。
|
|
18
|
+
def on(pattern, reply: nil, limit: nil, &block)
|
|
19
|
+
raise FrozenError, "responder is frozen" if frozen?
|
|
20
|
+
unless (pattern.is_a?(String) && !pattern.empty?) || pattern.is_a?(Regexp)
|
|
21
|
+
raise ArgumentError, "response pattern must be a nonempty String or Regexp"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
validate_action!(reply, limit, block)
|
|
25
|
+
|
|
26
|
+
pattern = pattern.b.freeze if pattern.is_a?(String)
|
|
27
|
+
@rules << [pattern, reply&.b&.freeze, limit, block].freeze
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 等待完成模式或 EOF,同时自动处理提示;timeout 沿用会话默认值,应答不重置期限。
|
|
32
|
+
# 完成块使用 PatternList DSL;Result 只描述最后一次匹配,全文请用会话 transcript 保存。
|
|
33
|
+
# 每次等待快照规则,回调修改规则仅影响后续等待;不接管或关闭会话。
|
|
34
|
+
def expect(session, *patterns, timeout: (session.timeout if session.is_a?(Session)), deadline: nil, &block)
|
|
35
|
+
raise ArgumentError, "responder requires a Session" unless session.is_a?(Session)
|
|
36
|
+
|
|
37
|
+
raise ArgumentError, "provide patterns or a pattern block, not both" if block && !patterns.empty?
|
|
38
|
+
|
|
39
|
+
rules = @rules.dup.freeze
|
|
40
|
+
Expect.__send__(
|
|
41
|
+
:run_expect, [session], [], timeout, deadline:, consume: true,
|
|
42
|
+
reset_timeout_on_read: false, match_by_position: true
|
|
43
|
+
) do |list|
|
|
44
|
+
patterns.each do |pattern|
|
|
45
|
+
case pattern
|
|
46
|
+
when :eof then list.eof
|
|
47
|
+
when :timeout then list.timeout
|
|
48
|
+
else list.on(pattern)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
if block
|
|
52
|
+
block.parameters.empty? ? list.instance_exec(&block) : block.call(list)
|
|
53
|
+
end
|
|
54
|
+
unless list.sessions.all? { |source| source.equal?(session) }
|
|
55
|
+
raise ArgumentError, "response completion patterns must use the same Session"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
register(list, rules)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# 摘要不展示模式、回复或回调闭包,避免意外记录凭据。
|
|
63
|
+
def inspect = "#<#{self.class} rules=#{@rules.size}>"
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# 静态回复与动作块互斥,次数必须是明确的正整数,不做宽松转换。
|
|
68
|
+
def validate_action!(reply, limit, block)
|
|
69
|
+
unless (reply.is_a?(String) && !block) || (reply.nil? && block)
|
|
70
|
+
raise ArgumentError, "provide a String reply or a response block, not both"
|
|
71
|
+
end
|
|
72
|
+
return if limit.nil? || (limit.is_a?(Integer) && limit.positive?)
|
|
73
|
+
|
|
74
|
+
raise ArgumentError, "response limit must be a positive Integer"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# 计数只属于本轮;先检查再发送,不重试写入或回调失败的部分副作用。
|
|
78
|
+
def register(list, rules)
|
|
79
|
+
rules.each_with_index do |(pattern, reply, limit, block), index|
|
|
80
|
+
count = 0
|
|
81
|
+
list.on(pattern) do |session|
|
|
82
|
+
raise ResponseError, "response rule #{index + 1} matched empty text" if session.match.empty?
|
|
83
|
+
raise ResponseError, "response rule #{index + 1} exceeded its limit" if limit && count >= limit
|
|
84
|
+
|
|
85
|
+
count += 1
|
|
86
|
+
block ? block.call(session) : session.write(reply)
|
|
87
|
+
CONTINUE_WITHOUT_RESET
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/expect/session.rb
CHANGED
|
@@ -5,7 +5,7 @@ require_relative "interaction"
|
|
|
5
5
|
|
|
6
6
|
module Expect
|
|
7
7
|
# 一个真实 PTY 或 IO 会话;缓冲、最近结果和所属进程都保存在本对象中。
|
|
8
|
-
#
|
|
8
|
+
# ResourceOwner 保存可独立回收的资源账本;Matcher、Relay 仅在操作期间借用会话状态。
|
|
9
9
|
# 日志与协议目标属于调用方,显式关闭和 GC 均不得接管这些借用对象。
|
|
10
10
|
class Session
|
|
11
11
|
include Logging
|
|
@@ -58,7 +58,7 @@ module Expect
|
|
|
58
58
|
|
|
59
59
|
@slave.raw! if raw
|
|
60
60
|
from_child = to_parent = nil
|
|
61
|
-
Cleanup.always(-> {
|
|
61
|
+
Cleanup.always(-> { ResourceOwner.close_handles(from_child, to_parent) }) do
|
|
62
62
|
# 错误管道的写端在 exec 成功时自动关闭;父进程据此区分成功启动与 exec 前失败。
|
|
63
63
|
# 管道返回到多重赋值之间也属于接管区间,确保 ensure 始终能找到两个端点。
|
|
64
64
|
Thread.handle_interrupt(Object => :never) { from_child, to_parent = IO.pipe }
|
|
@@ -181,7 +181,8 @@ module Expect
|
|
|
181
181
|
raise IOError, "closed Expect session" if closed? || writer.closed?
|
|
182
182
|
|
|
183
183
|
begin
|
|
184
|
-
|
|
184
|
+
# 单对象不需要中间数组与 join 副本;to_s、诊断和部分写入仍经过同一条路径。
|
|
185
|
+
data = objects.size == 1 ? objects.first.to_s.b : objects.map { |object| object.to_s.b }.join
|
|
185
186
|
trace_data(:sending, data)
|
|
186
187
|
rescue WriteTimeout
|
|
187
188
|
# 转换或诊断中的嵌套写入不属于当前命令;此时尚未向 writer 发送任何字节。
|
|
@@ -252,20 +253,28 @@ module Expect
|
|
|
252
253
|
|
|
253
254
|
# 先在自然退出期限内收集尾部输出,再关闭句柄并最多发送 TERM;不会发送 KILL。
|
|
254
255
|
# 尚未退出时返回 nil 并保留 PID,调用方可以继续等待或随后硬关闭。
|
|
256
|
+
# 读尾或交付失败仍完成软关闭,保留主异常;失败路径跳过自然等待,不升级为 KILL。
|
|
255
257
|
def soft_close(timeout: 15, term_timeout: 1)
|
|
256
258
|
period = Expect.duration(timeout)
|
|
257
259
|
term_timeout = Expect.duration(term_timeout)
|
|
258
260
|
raise ArgumentError, "term_timeout must be finite" unless term_timeout
|
|
259
261
|
|
|
260
262
|
deadline = period && (Expect.monotonic + period)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
263
|
+
status = nil
|
|
264
|
+
cleanup = lambda do |completed|
|
|
265
|
+
# 读尾失败后不再等待自然退出,尤其不能用 nil 预算无限阻塞异常传播。
|
|
266
|
+
remaining = completed ? (deadline && [deadline - Expect.monotonic, 0].max) : 0
|
|
267
|
+
status = close_resources(timeout: remaining, term_timeout:, force: false)
|
|
268
|
+
end
|
|
269
|
+
Cleanup.always(cleanup, report_completion: true) do
|
|
270
|
+
until eof?
|
|
271
|
+
remaining = deadline && [deadline - Expect.monotonic, 0].max
|
|
272
|
+
break if remaining&.zero? || !to_io.wait_readable(remaining)
|
|
264
273
|
|
|
265
|
-
|
|
274
|
+
read_available
|
|
275
|
+
end
|
|
266
276
|
end
|
|
267
|
-
|
|
268
|
-
term_timeout:, force: false)
|
|
277
|
+
status
|
|
269
278
|
end
|
|
270
279
|
|
|
271
280
|
# 立即关闭句柄,再分阶段等待、TERM、KILL;不收集剩余输出,返回已回收状态或 nil。
|
|
@@ -290,7 +299,7 @@ module Expect
|
|
|
290
299
|
if @resources
|
|
291
300
|
close(graceful:)
|
|
292
301
|
elsif own
|
|
293
|
-
|
|
302
|
+
ResourceOwner.close_handles(reader, writer, slave)
|
|
294
303
|
end
|
|
295
304
|
end
|
|
296
305
|
|
|
@@ -323,7 +332,8 @@ module Expect
|
|
|
323
332
|
# 记录超时、EOF 或原始 IO 异常,保留当前缓冲快照并清除旧匹配及捕获组。
|
|
324
333
|
# @api private
|
|
325
334
|
def record_error(error)
|
|
326
|
-
|
|
335
|
+
# Result 自行复制冻结文本,无须先通过公开 buffer 再复制一遍。
|
|
336
|
+
@last_result = Result.new(error:, before: @buffer, session: self, captures: [])
|
|
327
337
|
end
|
|
328
338
|
|
|
329
339
|
# 输入结束时将剩余缓冲放入 before 并清空,尝试回收但不终止仍活跃的子进程。
|
|
@@ -386,7 +396,7 @@ module Expect
|
|
|
386
396
|
# 初始化前先登记所有权;参数校验失败也能释放已取得的端点。
|
|
387
397
|
def initialize_io(reader, writer:, slave: nil, own: false, timeout: nil, write_timeout: nil,
|
|
388
398
|
buffer_limit: nil, logger: nil, transcript: nil, outputs: [])
|
|
389
|
-
@resources =
|
|
399
|
+
@resources = ResourceOwner.new(reader, writer:, slave:, own:)
|
|
390
400
|
raise ArgumentError, "reader must be a real IO" unless reader.is_a?(IO) && !reader.closed?
|
|
391
401
|
raise ArgumentError, "writer must be a real IO" unless writer.is_a?(IO) && !writer.closed?
|
|
392
402
|
|
data/lib/expect/version.rb
CHANGED
data/lib/expect.rb
CHANGED
|
@@ -5,12 +5,13 @@ require "io/console"
|
|
|
5
5
|
require "stringio"
|
|
6
6
|
require_relative "expect/version"
|
|
7
7
|
require_relative "expect/result"
|
|
8
|
-
require_relative "expect/
|
|
8
|
+
require_relative "expect/resource_owner"
|
|
9
9
|
require_relative "expect/pattern"
|
|
10
10
|
require_relative "expect/pattern_list"
|
|
11
11
|
require_relative "expect/matcher"
|
|
12
12
|
require_relative "expect/cleanup"
|
|
13
13
|
require_relative "expect/session"
|
|
14
|
+
require_relative "expect/responder"
|
|
14
15
|
|
|
15
16
|
# PTY 会话的创建、匹配与转接入口;可变状态只属于返回的 Session。
|
|
16
17
|
module Expect
|
|
@@ -72,7 +73,7 @@ module Expect
|
|
|
72
73
|
if session && (block_given? || !initialized)
|
|
73
74
|
session.cleanup_session(io, writer:, own:, graceful: initialized && graceful)
|
|
74
75
|
elsif !session && own
|
|
75
|
-
|
|
76
|
+
ResourceOwner.close_handles(io, writer)
|
|
76
77
|
end
|
|
77
78
|
end
|
|
78
79
|
Cleanup.always(cleanup) do
|
|
@@ -149,7 +150,8 @@ module Expect
|
|
|
149
150
|
end
|
|
150
151
|
|
|
151
152
|
# 声明完成后固定模式;有参数块保留调用方 self,无参数块使用模式 DSL。
|
|
152
|
-
def run_expect(sessions, patterns, timeout, deadline:, consume:, reset_timeout_on_read:,
|
|
153
|
+
def run_expect(sessions, patterns, timeout, deadline:, consume:, reset_timeout_on_read:,
|
|
154
|
+
match_by_position: false, &block)
|
|
153
155
|
timeout = duration(timeout)
|
|
154
156
|
deadline = Float(deadline) unless deadline.nil?
|
|
155
157
|
raise ArgumentError, "deadline must be finite" if deadline && !deadline.finite?
|
|
@@ -159,7 +161,7 @@ module Expect
|
|
|
159
161
|
if block
|
|
160
162
|
block.parameters.empty? ? pattern_list.instance_exec(&block) : block.call(pattern_list)
|
|
161
163
|
end
|
|
162
|
-
Matcher.new(pattern_list, timeout, deadline:, consume:, reset_timeout_on_read:).run
|
|
164
|
+
Matcher.new(pattern_list, timeout, deadline:, consume:, reset_timeout_on_read:, match_by_position:).run
|
|
163
165
|
end
|
|
164
166
|
end
|
|
165
167
|
end
|
data/sig/expect.rbs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 公开 Session 接口;内部匹配与转接协议在源码中标记为 @api private。
|
|
2
2
|
module Expect
|
|
3
3
|
type duration = Numeric | String
|
|
4
4
|
type pattern = String | Regexp | :eof | :timeout
|
|
@@ -263,6 +263,21 @@ module Expect
|
|
|
263
263
|
def timeout: () ?{ (Array[Session]) -> untyped } -> PatternList
|
|
264
264
|
end
|
|
265
265
|
|
|
266
|
+
class Responder
|
|
267
|
+
class ResponseError < StandardError
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def initialize: () ?{ (Responder) -> untyped } -> void
|
|
271
|
+
|
|
272
|
+
def on: ((String | Regexp) pattern, ?reply: String?, ?limit: Integer?)
|
|
273
|
+
?{ (Session) -> untyped } -> Responder
|
|
274
|
+
|
|
275
|
+
def expect: (Session session, *pattern patterns, ?timeout: duration?, ?deadline: duration?)
|
|
276
|
+
?{ (PatternList) -> untyped } -> Result
|
|
277
|
+
|
|
278
|
+
def inspect: () -> String
|
|
279
|
+
end
|
|
280
|
+
|
|
266
281
|
class Redactor
|
|
267
282
|
def self.redact: (String data, Array[String] patterns, ?replacement: String) -> String
|
|
268
283
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: expect-pty
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- expect-pty contributors
|
|
@@ -76,9 +76,10 @@ files:
|
|
|
76
76
|
- lib/expect/redactor.rb
|
|
77
77
|
- lib/expect/relay.rb
|
|
78
78
|
- lib/expect/relay_writer.rb
|
|
79
|
+
- lib/expect/resource_owner.rb
|
|
80
|
+
- lib/expect/responder.rb
|
|
79
81
|
- lib/expect/result.rb
|
|
80
82
|
- lib/expect/session.rb
|
|
81
|
-
- lib/expect/session_resources.rb
|
|
82
83
|
- lib/expect/version.rb
|
|
83
84
|
- sig/expect.rbs
|
|
84
85
|
homepage: https://github.com/gatework/expect-ruby
|