expect-pty 0.9.6 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24f49a4a4e864cdcfb8bd248b9ca01d7de830098f0767a0a1b5829c2569fe386
4
- data.tar.gz: 057d8e7efa345d73790a47e35390d92f643ec211746b064105cc1fb2c4da628f
3
+ metadata.gz: eabbf4b5d408191298426e8bc078219eacd371b58fd0cb06bdfda6ca26d43f22
4
+ data.tar.gz: 6735e5a170c9b82f8043909001004bcb705f6be5971bfdd0c96db495f2cef29c
5
5
  SHA512:
6
- metadata.gz: e7c9b71e0c1f74b891f9773cf438ef09ef1e9496faa0414af7048b4090efe86f141f84aa72eca9f5a4e0f1b21f69889f3084a1922158b6addfc80346d91bb423
7
- data.tar.gz: 220a9fda4d96aca8c632cceb408f4c1bcc705c79ab92cc177520c5ce72c534888c96282e5613110d84f07e6594eed5923b13bbfdc356def25c54a046ce3a1b96
6
+ metadata.gz: 16e185ae81ce6355aa215c2e91c42ad625c6082317f19f45f51e9f300bfe937318f75f3de81183cd405f9fafaf4ecd99709a54502efd089d10d7d24011c194eb
7
+ data.tar.gz: ab419a9535eed4647d6b4c095caf6a0af84cd5522e5213fe2b186c4dd5ca67428300b40025843d56705a7a9e32b5c3d2a1f8ec29a803c1c3d794405850c3f178
data/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.10.0 - 2026-10-30
6
+
7
+ - 最低 Ruby 升级至 4.0.6;Result 明确区分 matched、timeout、eof、error,编号统一为 match_number,error 只保存原始异常。构造和 with 拒绝矛盾状态,旧 number 字段移除。
8
+ - 保留既有模块结构,将日志与转接状态初始化归回所属模块,协议输出归 Interaction 管理;等待与转接发送诊断使用显式内部协作入口。
9
+ - 转义规则变更后首次使用时统一编译,读取期间复用正则包装与冻结文本规则快照;保持优先级、UTF-8、零宽校验、跨次 interact 历史和短写恢复契约。
10
+ - API 检查同时拒绝已移除接口的残留 RBS 声明;同步迁移示例、签名和安装验证。
11
+
5
12
  ## 0.9.6 - 2026-10-23
6
13
 
7
14
  - 移除 io-console、json、logger 和 stringio 的额外版本下限,沿用 Ruby 3.4+ 的运行时要求。
data/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  A Ruby library for automating interactive programs through POSIX pseudo-terminals. Start a child process, wait for text or regular expressions, send input, and manage timeouts, callbacks, and cleanup with Ruby objects and blocks.
8
8
 
9
- RubyGems package: **expect-pty**. Requires Ruby 3.4+ on Linux or macOS.
9
+ RubyGems package: **expect-pty**. Requires Ruby 4.0.6+ on Linux or macOS.
10
10
 
11
11
  ## Install
12
12
 
data/README.zh-CN.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  一个通过 POSIX 伪终端自动操作交互式程序的 Ruby 库。启动子进程、等待文本或正则匹配、发送输入,并用 Ruby 对象和代码块管理超时、回调与资源清理。
8
8
 
9
- RubyGems 包名:**expect-pty**。要求 Ruby 3.4+,支持 Linux 和 macOS。
9
+ RubyGems 包名:**expect-pty**。要求 Ruby 4.0.6+,支持 Linux 和 macOS。
10
10
 
11
11
  ## 安装
12
12
 
data/docs/API.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Ruby API
2
2
 
3
- 支持 Ruby 3.4+ 和 Linux/macOS。加载入口为 `require "expect/pty"`,独立流过滤器可加载 `expect/redactor`。
3
+ 支持 Ruby 4.0.6+ 和 Linux/macOS。加载入口为 `require "expect/pty"`,独立流过滤器可加载 `expect/redactor`。
4
4
  `Expect` 是模块;`Expect::Session` 是公开的真实会话。工厂、回调、链式方法与 `Result#session` 使用同一个 Session。
5
5
 
6
6
  ## 创建与关闭
@@ -37,7 +37,7 @@ nil。
37
37
  `Expect::Result`。
38
38
  `Expect.expect` 使用相同操作关键字,另加 `from:` 指定默认来源;其 timeout 默认 nil。模式为 String、Regexp、`:eof` 或
39
39
  `:timeout`,
40
- 位置模式与声明块不能混用。`number` 为从 1 开始的文本模式序号或 nil,事件声明也占序号;使用 `matched?` 判断成功。
40
+ 位置模式与声明块不能混用。`match_number` 为从 1 开始的文本模式序号或 nil,事件声明也占序号;使用 `matched?` 判断成功。
41
41
 
42
42
  ```ruby
43
43
  result = session.expect(timeout: 3) do |patterns|
@@ -47,7 +47,7 @@ result = session.expect(timeout: 3) do |patterns|
47
47
  end
48
48
  patterns.on(/hello (\w+)/)
49
49
  end
50
- result => { number:, captures: }
50
+ result => { status: :matched, match_number:, captures: }
51
51
  ```
52
52
 
53
53
  无参数声明块在 PatternList 上运行,直接调用 `on`、`eof`、`timeout`;有参数块保留调用者 self。
@@ -69,11 +69,15 @@ nil 保留全缓冲扫描;指定时,字符串和正则只搜索最新的尾
69
69
 
70
70
  Result 的 `inspect` 与 `to_s` 仅显示匹配序号和错误状态,不打印原始文本、异常消息或来源对象。
71
71
  需要原始结果时显式读取字段;`redact` 不改变这些字段。
72
- Result 字段为 `number`、`error`、`match`、`before`、`after`、`session`、`captures`。对象、文本及捕获数组均不可变,未参与捕获为
73
- nil。
74
- 错误为 nil、`:timeout`、`:eof` 或原始 IO 异常;来源会话与异常不复制、不冻结。使用 `matched?`、`timeout?`、`eof?` 查询,
75
- 使用 `to_h`、位置/键模式解构或 `with` 构造新结果;没有字段 writer 或 `to_a`。
76
- 会话的 `last_result`、`before`、`after`、`match`、`match_number`、`captures`、`error` 读取最近结果。
72
+ Result 字段按顺序为 `status`、`match_number`、`error`、`match`、`before`、`after`、`session`、`captures`。
73
+ 状态为 `:matched`、`:timeout`、`:eof` 或 `:error`;`error` 只保存原始异常或 nil,等待超时和 EOF 均不是异常。
74
+ 使用 `matched?`、`timeout?`、`eof?`、`error?` 查询。仅匹配状态具有正整数编号和字符串 match(包括零宽匹配的空串);
75
+ 其余状态没有编号、match、after 或捕获,只有 `:error` 状态携带异常。参数、编码与回调异常仍按原规则直接抛出。
76
+
77
+ 对象、文本及捕获数组均不可变,未参与的捕获为 nil;来源会话与异常不复制、不冻结。
78
+ 使用 `to_h`、位置/键模式解构或 `with` 构造新结果;构造必须指定 status,new、[]、with 都拒绝矛盾状态与非字符串文本。
79
+ 没有字段 writer、旧 number 或 to_a。会话的 `last_result`、`before`、`after`、`match`、`match_number`、`captures`、`error`
80
+ 读取最近结果;最近等待状态使用 `last_result.status`,与输入 EOF、IO 关闭、进程状态分别查询。
77
81
 
78
82
  匹配、捕获按原始字节保存;文本显示时先 `dup` 再 `force_encoding`。固定 UTF-8 正则等待尾部字符收齐,非法编码抛出
79
83
  EncodingError。
@@ -95,7 +99,7 @@ end.freeze
95
99
  commands.each do |command|
96
100
  session.puts(command)
97
101
  result = rules.expect(session, "device>", timeout: 30, deadline: Expect.monotonic + 60)
98
- raise "command incomplete: #{result.error}" unless result.matched?
102
+ raise "command incomplete: #{result.status}" unless result.matched?
99
103
  end
100
104
  ```
101
105
 
@@ -36,7 +36,8 @@ Austin Schutz、Roland Giersig、Dave Jacoby,采用与 Perl 相同的许可。
36
36
  UTF-8、EOF/超时回调、绝对期限与接收重置、多会话、缓冲上限、慢速写入和背压、借用 writer 的接收记录、标准 Logger 诊断、outputs
37
37
  转发图、人工接管、跨读取转义及终端恢复。
38
38
 
39
- `expect` / `last_result` 返回不可变七字段 `Data`,匹配序号通过 `number` 读取。使用 `to_h` 或模式解构;
39
+ `expect` / `last_result` 返回不可变八字段 `Data`,匹配序号通过 `match_number` 读取,等待结果通过 `status` 查询。
40
+ `error` 只保存原始异常或 nil;EOF 与超时各有独立状态。使用 `to_h` 或模式解构;
40
41
  字段及文本快照不可修改,不提供 `to_a`。日志按实际读取时记录,转接与匹配切换不重复写日志;显式启用 `redact` 时过滤已注册秘密。
41
42
 
42
43
  ## 软关闭与硬关闭
data/docs/MIGRATION.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # 迁移说明
2
2
 
3
+ ## 0.9.x → 0.10.0:Ruby 4 与明确的等待结果
4
+
5
+ - 最低运行环境为 Ruby 4.0.6,保留 Linux/macOS 支持;本地开发使用 RVM。
6
+ - `result.number` 改为 `result.match_number`,与 Session 的快照访问器一致,不提供旧字段别名。
7
+ - `result.error == :timeout` / `:eof` 改用 `result.timeout?` / `result.eof?`,或判断 `result.status`。
8
+ `error` 现在只保存原始异常或 nil;`session.error` 同样遵守此约定,最近等待状态为 `session.last_result.status`。
9
+ - Result 的位置参数与解构在开头加入 status,第二项为 match_number;优先使用键模式。
10
+ 手工构造匹配结果用 `Result.new(status: :matched, match_number: 1, match: "ready")`,
11
+ EOF 用 `Result.new(status: :eof, before: "tail")`,IO 错误用 `Result.new(status: :error, error: exception)`。
12
+ - `new`、`[]`、`with` 校验状态一致性:修改匹配结果为超时还须清除编号、match、after、captures。
13
+ 输入 EOF、IO 关闭、子进程退出继续独立表达,关闭和命令发送策略不变。
14
+ - net-connector 使用配套 0.22.x;它在 PTY 适配层转换结果,上层设备事件和零基索引协议保持不变。
15
+
16
+ 内部保留既有类与混入模块。转义注册表改为冻结快照;内部恢复须使用 sequences=,避免绕过缓存更新。
17
+ 历史按实际扫描规则失效,临时 interact 注册与恢复仍支持同规则跨次接管。
18
+
3
19
  ## 0.8.x → 0.9.0:启动与时间参数
4
20
 
5
21
  - 始终使用 `require "expect/pty"`,Gemfile 指定 `require: "expect/pty"`。本库删除 `lib/expect.rb` 后,
@@ -8,7 +8,7 @@ require_relative "literal_scan"
8
8
  # 为会话补充人工接管和多路 IO 转接;核心会话定义位于 session.rb。
9
9
  module Expect
10
10
  # 为公开 Session 混入转义注册与人工接管接口;协作方法各自标记为内部协议。
11
- # 宿主提供真实读写 IO、缓冲及 outputs;本模块保存转义、显示历史和待写游标。
11
+ # 宿主提供真实读写 IO 和缓冲;本模块保存输出目标、转义、显示历史和待写游标。
12
12
  # Relay 暂借输入缓冲并调度游标,Matcher 可在转义回调中临时接管,退出时归还未消费尾部。
13
13
  module Interaction
14
14
  # 正则没有“潜在部分匹配”接口,只保留有限历史;已转发的历史字节不能撤回。
@@ -54,7 +54,7 @@ module Expect
54
54
  end
55
55
  raise ArgumentError, "escape sequence must not be empty" if key == ""
56
56
 
57
- @sequences[key] = block || STOP
57
+ self.sequences = @sequences.merge(key => block || STOP).freeze
58
58
  self
59
59
  end
60
60
 
@@ -79,15 +79,34 @@ module Expect
79
79
  # 超时或异常后仍有未交付的数据;重新 interconnect 同一源会话可继续发送。
80
80
  def pending_output? = @pending_writes.any? { |output| !output.done? }
81
81
 
82
+ # 返回协议转发目标的副本,外部修改不能原地改变转发图。
83
+ def outputs = @outputs.dup
84
+
85
+ # 先校验整个数组再替换,失败时保留原输出图;目标始终由调用方拥有。
86
+ def outputs=(targets)
87
+ unless targets.is_a?(Array) && targets.all? { |target| target.respond_to?(:write) }
88
+ raise ArgumentError, "outputs must be an Array of writers"
89
+ end
90
+
91
+ @outputs = targets.dup
92
+ end
93
+
94
+ # 普通接收同步交付原始字节;日志脱敏不影响协议输出。
95
+ # @api private
96
+ def propagate(data)
97
+ @outputs.each { |output| emit(output, data) }
98
+ end
99
+
82
100
  # 将待处理输入排入唯一的转接发送队列;返回 :ready、:queued、:stopped 或 :timeout。
83
101
  # 字面序列暂存潜在前缀,正则序列结合历史匹配;final 为真时不再等待后续字节。
84
102
  # @api private
85
103
  def self.queue_input(session, buffer, final: false, deadline: nil, literal_scan: nil)
86
104
  loop do
87
- sequences = session.sequences.except(:eof)
105
+ sequences = session.text_sequences
88
106
  history = session.relay_history
89
107
  found, regexp_scanned, utf8_regexp = scan_sequences(
90
- buffer, sequences, history, final:, literal_scan:, generation: session.buffer_generation
108
+ buffer, sequences, history, final:, literal_scan:, generation: session.buffer_generation,
109
+ compiled: session.sequence_patterns
91
110
  )
92
111
  return :timeout if expired?(deadline)
93
112
 
@@ -141,17 +160,19 @@ module Expect
141
160
  end
142
161
 
143
162
  # 单轮正则共用一个组合文本;回调返回后下一轮重新取得规则和历史。
144
- def self.scan_sequences(buffer, sequences, history, final:, literal_scan: nil, generation: nil)
163
+ def self.scan_sequences(buffer, sequences, history, final:, literal_scan: nil, generation: nil, compiled: {})
145
164
  text = nil
146
165
  utf8_regexp = false
147
166
  earliest = nil
167
+ earliest_offset = Float::INFINITY
148
168
  # 只保存最早候选,同位置不替换先注册者;仍扫描全部正则以校验零宽规则并收集编码状态。
149
169
  sequences.each do |key, handler|
150
170
  if key.is_a?(Regexp)
151
171
  # 仅在本轮扫描复用组合文本;回调可能更换规则或消费缓冲,下一轮必须重新构造。
152
172
  text ||= history + buffer
153
173
  utf8_regexp ||= key.fixed_encoding? && key.encoding == Encoding::UTF_8
154
- position = Pattern.new(value: key).locate(text, final:, captures: false)
174
+ pattern = compiled.fetch(key) { Pattern.new(value: key) }
175
+ position = pattern.locate(text, final:, captures: false)
155
176
  next unless position
156
177
 
157
178
  offset, length, = position
@@ -165,7 +186,10 @@ module Expect
165
186
 
166
187
  length = key.bytesize
167
188
  end
168
- earliest = [offset, length, handler] if !earliest || offset < earliest.first
189
+ if offset < earliest_offset
190
+ earliest = [offset, length, handler]
191
+ earliest_offset = offset
192
+ end
169
193
  end
170
194
  [earliest, !text.nil?, utf8_regexp]
171
195
  end
@@ -173,11 +197,10 @@ module Expect
173
197
  # 普通转发和超时排出的字面前缀都属于正则历史,跨次转接时必须接续同一字节流。
174
198
  # @api private
175
199
  def self.remember_output(session, data)
176
- regexps = session.sequences.keys.grep(Regexp)
177
- return if regexps.empty?
200
+ return if session.sequence_patterns.empty?
178
201
 
179
- utf8 = regexps.any? { |regexp| regexp.fixed_encoding? && regexp.encoding == Encoding::UTF_8 }
180
- trim_history(session.relay_history, data, limit: session.buffer_limit || REGEXP_ESCAPE_HISTORY_LIMIT, utf8:)
202
+ trim_history(session.relay_history, data, limit: session.buffer_limit || REGEXP_ESCAPE_HISTORY_LIMIT,
203
+ utf8: session.utf8_sequences?)
181
204
  end
182
205
 
183
206
  # 只暂存可能拼成完整字面转义的最长后缀。
@@ -221,14 +244,54 @@ module Expect
221
244
  end
222
245
  end
223
246
 
224
- # 仅供转接内部保存和恢复注册表,避免公开可变 Hash 绕过 on_sequence 的校验。
247
+ # 冻结的注册快照只能通过 on_sequence 或内部恢复入口替换,不能绕过编译及历史失效。
225
248
  # @api private
226
249
  attr_reader :sequences
227
250
 
228
251
  # @api private
229
252
  def sequences=(sequences)
230
- @sequences = sequences
231
- @literal_prefix_tables&.delete_if { |sequence, _table| !sequences.key?(sequence) }
253
+ # Hash 的值相等忽略插入顺序;转义优先级必须同时比较声明顺序。
254
+ return if @sequences == sequences && @sequences.keys == sequences.keys
255
+
256
+ @sequences = sequences.frozen? ? sequences : sequences.dup.freeze
257
+ @literal_prefix_tables.delete_if { |sequence, _table| !sequences.key?(sequence) }
258
+ end
259
+
260
+ # 连续声明只发布注册快照;首次扫描统一编译,后续读取不重复创建集合及正则包装。
261
+ # @api private
262
+ def text_sequences
263
+ return @text_sequences if @compiled_sequences.equal?(@sequences)
264
+
265
+ registered = @sequences
266
+ text = registered.except(:eof).freeze
267
+ patterns = text.each_key.with_object({}) do |key, patterns|
268
+ patterns[key] = @sequence_patterns[key] || Pattern.new(value: key) if key.is_a?(Regexp)
269
+ end.freeze
270
+ utf8 = patterns.each_key.any? do |regexp|
271
+ regexp.fixed_encoding? && regexp.encoding == Encoding::UTF_8
272
+ end
273
+ # 编译结果一起发布;失败时旧缓存仍完整,下次扫描可以重新编译,不读取陈旧规则。
274
+ Thread.handle_interrupt(Object => :never) do
275
+ @text_sequences = text
276
+ @sequence_patterns = patterns
277
+ @utf8_sequences = utf8
278
+ @compiled_sequences = registered
279
+ end
280
+ text
281
+ end
282
+
283
+ # 冻结的正则包装快照;已存在的正则跨规则变更复用。
284
+ # @api private
285
+ def sequence_patterns
286
+ text_sequences
287
+ @sequence_patterns
288
+ end
289
+
290
+ # 正则历史的编码边界由当前已注册规则决定。
291
+ # @api private
292
+ def utf8_sequences?
293
+ text_sequences
294
+ @utf8_sequences
232
295
  end
233
296
  # 冻结字面规则对应的 KMP 前缀表,跨转接复用,避免每个读块重新编译长规则。
234
297
  # @api private
@@ -252,8 +315,9 @@ module Expect
252
315
  # 同规则保留跨次转接的连续历史;换规则或消费、改写输入后,不能拼接旧的前缀。
253
316
  # @api private
254
317
  def relay_history
255
- sequences = @sequences.except(:eof)
256
- @relay_history = "".b unless @relay_history_sequences == sequences
318
+ # interact 的临时注册和恢复不消费输入;只比较实际扫描时的规则,保留同规则跨次接管的历史。
319
+ sequences = text_sequences
320
+ @relay_history.clear unless @relay_history_sequences == sequences
257
321
  @relay_history_sequences = sequences
258
322
  @relay_history
259
323
  end
@@ -267,6 +331,22 @@ module Expect
267
331
 
268
332
  private
269
333
 
334
+ # 转接状态集中在所属模块初始化;缓冲和进程状态仍由 Session 管理。
335
+ def initialize_interaction
336
+ @outputs = []
337
+ @sequences = {}.freeze
338
+ @text_sequences = @sequence_patterns = @sequences
339
+ @compiled_sequences = @sequences
340
+ @utf8_sequences = false
341
+ @literal_prefix_tables = {}
342
+ @pending_writes = []
343
+ @interact_inputs = {}.compare_by_identity
344
+ @interact_output = nil
345
+ @interaction_buffer = @relay_owner = @relay_callback = nil
346
+ @relay_history = "".b
347
+ @relay_history_sequences = @text_sequences
348
+ end
349
+
270
350
  # 同一输入 IO 重用一个借用会话,以保留上次接管预读的尾部并避免不断积累包装器。
271
351
  def interact_source(input)
272
352
  return input if input.is_a?(Session)
@@ -3,12 +3,11 @@
3
3
  require "logger"
4
4
  require_relative "redactor"
5
5
 
6
- # 借用标准 Logger、接收记录 writer 和协议输出 writer,不接管它们的生命周期。
6
+ # 借用标准 Logger 和接收记录 writer,不接管它们的生命周期。
7
7
  # 脱敏只作用于诊断与接收记录,协议输出和匹配缓冲始终保留原始字节。
8
8
  module Expect
9
- # 会话诊断、接收记录和协议转发的独立输出接口。
10
- # 宿主 Session 初始化目标与过滤器状态,并提供 pid、fileno 作为诊断元数据。
11
- # 三种输出各有边界:Logger 接收事件,transcript 接收脱敏字节,outputs 接收原协议字节。
9
+ # 会话诊断与接收记录接口;Session 构造时调用本模块初始化过滤器,并提供 pid、fileno 元数据。
10
+ # Logger 接收事件,transcript 接收脱敏字节;Interaction 的协议输出只复用同步 writer 交付。
12
11
  module Logging
13
12
  # 调用方提供的诊断 Logger 与接收记录 writer,均由调用方管理生命周期。
14
13
  attr_reader :logger, :transcript
@@ -68,26 +67,21 @@ module Expect
68
67
  nil
69
68
  end
70
69
 
71
- # 返回副本,避免外部原地修改转发关系;stdout 与其他 writer 使用相同协议。
72
- def outputs = @outputs.dup
73
-
74
- # 先校验整个数组再替换,失败时保留原输出图。
75
- def outputs=(targets)
76
- unless targets.is_a?(Array) && targets.all? { |target| target.respond_to?(:write) }
77
- raise ArgumentError, "outputs must be an Array of writers"
78
- end
79
-
80
- @outputs = targets.dup
81
- end
82
-
83
- # 原样转发协议字节,接收记录在读取时单独写入。
70
+ # 转接确认交付后记录发送字节;调用者已登记游标,诊断失败不能回退发送进度。
84
71
  # @api private
85
- def propagate(data)
86
- @outputs.each { |output| emit(output, data) }
72
+ def record_sent(data)
73
+ trace_data(:sending, data)
87
74
  end
88
75
 
89
76
  private
90
77
 
78
+ # 每个日志方向独立保留脱敏状态;协议缓冲及转发图属于 Session 与 Interaction。
79
+ def initialize_logging
80
+ @secrets = @transcript_redactor = nil
81
+ @diagnostic_redactors = {}
82
+ @logger = @transcript = nil
83
+ end
84
+
91
85
  # 关闭时两种记录都要尝试完成;后续 writer 的常规异常不能覆盖先发生的诊断异常。
92
86
  # 不关闭借用目标,新的 Interrupt/SystemExit 仍由 Cleanup 原样传播。
93
87
  def finish_logging
@@ -255,7 +255,7 @@ module Expect
255
255
  # 为活跃会话记录超时,回调接收全部活跃源;只有重置计时的继续符号能重新等待。
256
256
  # 已到总期限仍通知超时回调,但不接受继续请求,且不消费尚未匹配的字节。
257
257
  def handle_timeout
258
- results = active_sessions.map { |session| session.record_error(:timeout) }
258
+ results = active_sessions.map(&:record_timeout)
259
259
  action = @patterns.timeout_pattern&.call(active_sessions)
260
260
  return results.first unless CONTINUE.equal?(action) && !hard_expired?
261
261
 
@@ -3,10 +3,10 @@
3
3
  module Expect
4
4
  # 一个已编号的文本模式或事件及其回调;匹配结果统一使用字节偏移,便于精确消费 IO 缓冲。
5
5
  # @api private
6
- Pattern = Data.define(:number, :value, :callback) do
6
+ Pattern = Data.define(:match_number, :value, :callback) do
7
7
  # 复制字符串模式;Regexp 和 callable 只引用,不取得外部对象的生命周期。
8
- def initialize(value:, number: nil, callback: nil)
9
- super(value: value.is_a?(String) ? value.b.freeze : value, number:, callback:)
8
+ def initialize(value:, match_number: nil, callback: nil)
9
+ super(value: value.is_a?(String) ? value.b.freeze : value, match_number:, callback:)
10
10
  end
11
11
 
12
12
  # 将匹配会话或超时会话数组交给回调;额外上下文由调用方闭包保存。
@@ -14,7 +14,7 @@ module Expect
14
14
  validate_sessions!(@default_sessions) unless @default_sessions.empty?
15
15
  # 保留空模式的默认会话组,使 expect(timeout:) 也能只收集输出而不匹配文本。
16
16
  @groups = @default_sessions.empty? ? [] : [[@default_sessions, []]]
17
- @number = 0
17
+ @match_number = 0
18
18
  patterns.each do |value|
19
19
  case value
20
20
  when :eof then eof
@@ -140,8 +140,8 @@ module Expect
140
140
 
141
141
  # 按注册顺序分配从 1 开始的序号,文本模式与事件共用编号。
142
142
  def build(value, callback)
143
- @number += 1
144
- Pattern.new(number: @number, value:, callback:)
143
+ @match_number += 1
144
+ Pattern.new(match_number: @match_number, value:, callback:)
145
145
  end
146
146
 
147
147
  # 拒绝空来源和非 Session 对象,在任何 IO 读取之前暴露调用错误。
data/lib/expect/pty.rb CHANGED
@@ -188,6 +188,8 @@ module Expect
188
188
  end
189
189
 
190
190
  # 声明完成后固定模式;有参数块保留调用方 self,无参数块使用模式 DSL。
191
+ # Session 与 Responder 共用的内部入口,不通过反射绕过方法边界。
192
+ # @api private
191
193
  def run_expect(sessions, patterns, timeout, deadline:, consume:, reset_timeout_on_read:,
192
194
  match_by_position: false, search_window: nil, &block)
193
195
  unless search_window.nil? || (search_window.is_a?(Integer) && search_window.positive?)
@@ -205,5 +207,7 @@ module Expect
205
207
  Matcher.new(pattern_list, timeout, deadline:, consume:, reset_timeout_on_read:, match_by_position:,
206
208
  search_window:).run
207
209
  end
210
+
211
+ public :run_expect
208
212
  end
209
213
  end
@@ -89,7 +89,7 @@ module Expect
89
89
 
90
90
  # 诊断可嵌套写入;外层只报告本游标的进度,原超时及其内层进度保留在 cause。
91
91
  def trace_delivery(data)
92
- target.__send__(:trace_data, :sending, data)
92
+ target.record_sent(data)
93
93
  rescue WriteTimeout
94
94
  raise WriteTimeout.new("relay interrupted by a diagnostic timeout", bytes_written: @offset)
95
95
  end
@@ -38,9 +38,9 @@ module Expect
38
38
  raise ArgumentError, "provide patterns or a pattern block, not both" if block && !patterns.empty?
39
39
 
40
40
  rules = @rules.dup.freeze
41
- Expect.__send__(
42
- :run_expect, [session], [], timeout, deadline:, consume: true,
43
- reset_timeout_on_read: false, match_by_position: true, search_window:
41
+ Expect.run_expect(
42
+ [session], [], timeout, deadline:, consume: true,
43
+ reset_timeout_on_read: false, match_by_position: true, search_window:
44
44
  ) do |list|
45
45
  patterns.each do |pattern|
46
46
  case pattern
data/lib/expect/result.rb CHANGED
@@ -21,38 +21,60 @@ module Expect
21
21
  # 按成员声明顺序返回字段数组。
22
22
  # @!method deconstruct_keys(keys)
23
23
  # 按指定键解构结果,nil 表示全部字段。
24
- Result = Data.define(:number, :error, :match, :before, :after, :session, :captures) do
24
+ Result = Data.define(:status, :match_number, :error, :match, :before, :after, :session, :captures) do
25
25
  # 构造独立不可变快照,复制字符串和捕获数组。
26
26
  # @param captures [Array<String, nil>] 按声明顺序保存捕获组,未参与的组保留 nil。
27
- def initialize(number: nil, error: nil, match: nil, before: nil, after: nil, session: nil, captures: [])
28
- super(number:, error:, session:, match: match&.dup&.freeze,
29
- before: before&.dup&.freeze, after: after&.dup&.freeze,
30
- captures: captures.map { |value| value&.dup&.freeze }.freeze)
27
+ def initialize(status:, match_number: nil, error: nil, match: nil, before: nil, after: nil,
28
+ session: nil, captures: [])
29
+ raise ArgumentError, "captures must be an Array" unless captures.is_a?(Array)
30
+
31
+ validate_outcome!(status, match_number, error, match, after, captures)
32
+ super(status:, match_number:, error:, session:, match: copy_text(match), before: copy_text(before),
33
+ after: copy_text(after), captures: captures.map { |value| copy_text(value) }.freeze)
31
34
  end
32
35
 
33
36
  # 诊断只显示匹配序号和错误状态,不检查原始文本、异常或来源对象。
34
37
  # @return [String] 不包含协议内容的结果摘要。
35
- def inspect
36
- state = if error.nil? then "none"
37
- elsif error.equal?(:eof) then "eof"
38
- elsif error.equal?(:timeout) then "timeout"
39
- else "exception"
40
- end
41
- index = number.is_a?(Integer) ? number.to_s : "nil"
42
- "#<#{self.class} number=#{index} error=#{state}>"
43
- end
38
+ def inspect = "#<#{self.class} status=#{status} match_number=#{match_number.inspect}>"
44
39
 
45
40
  # Data 的默认 to_s 独立于 inspect;字符串插值同样只使用安全摘要。
46
41
  # @return [String] 与 inspect 相同的结果摘要。
47
42
  def to_s = inspect
48
43
 
49
44
  # 是否命中文本;EOF 和超时没有模式序号。
50
- def matched? = !number.nil?
45
+ def matched? = status == :matched
51
46
 
52
47
  # 是否因等待期限到达而返回。
53
- def timeout? = error == :timeout
48
+ def timeout? = status == :timeout
54
49
 
55
50
  # 是否读取源已经结束,子进程状态由会话单独查询。
56
- def eof? = error == :eof
51
+ def eof? = status == :eof
52
+
53
+ # 是否因原始 IO 异常结束;等待事件不属于异常。
54
+ def error? = status == :error
55
+
56
+ private
57
+
58
+ # 正文和捕获使用同一快照边界;不转换任意对象,也不冻结调用方的字符串。
59
+ def copy_text(value)
60
+ return nil if value.nil?
61
+
62
+ raise ArgumentError, "result text must be a String or nil" unless value.is_a?(String)
63
+
64
+ value.dup.freeze
65
+ end
66
+
67
+ # 状态是唯一判据;new、[] 和 with 都经过同一边界,不能产生相互矛盾的结果。
68
+ def validate_outcome!(status, match_number, error, match, after, captures)
69
+ valid = case status
70
+ when :matched
71
+ match_number.is_a?(Integer) && match_number.positive? && match.is_a?(String) && error.nil?
72
+ when :eof, :timeout, :error
73
+ match_number.nil? && match.nil? && after.nil? && captures.empty? &&
74
+ (status == :error ? error.is_a?(Exception) : error.nil?)
75
+ else false
76
+ end
77
+ raise ArgumentError, "inconsistent result status and fields" unless valid
78
+ end
57
79
  end
58
80
  end
@@ -50,8 +50,8 @@ module Expect
50
50
  # 等待自身输入,返回 Result;匹配消费和接收重置策略只对本轮有效。
51
51
  def expect(*patterns, timeout: self.timeout, deadline: nil, consume: true, reset_timeout_on_read: false,
52
52
  search_window: nil, &)
53
- Expect.__send__(:run_expect, [self], patterns, timeout,
54
- deadline:, consume:, reset_timeout_on_read:, search_window:, &)
53
+ Expect.run_expect([self], patterns, timeout,
54
+ deadline:, consume:, reset_timeout_on_read:, search_window:, &)
55
55
  end
56
56
 
57
57
  # 在新控制终端中执行命令并同步确认 exec 结果;同一会话只能启动一次。
@@ -119,12 +119,12 @@ module Expect
119
119
  def match = @last_result&.match
120
120
 
121
121
  # 最近命中的模式序号;EOF、超时或尚无结果时为 nil。
122
- def match_number = @last_result&.number
122
+ def match_number = @last_result&.match_number
123
123
 
124
124
  # 最近捕获值的不可变数组;未参与的捕获为 nil。
125
125
  def captures = @last_result&.captures || []
126
126
 
127
- # 最近的 EOF、超时事件或原始 IO 异常。
127
+ # 最近结果的原始 IO 异常;等待状态通过 last_result.status 查询。
128
128
  def error = @last_result&.error
129
129
 
130
130
  # 返回缓冲副本,防止调用方原地修改绕过裁剪规则。
@@ -309,7 +309,8 @@ module Expect
309
309
  # @api private
310
310
  def record_match(pattern, position, consume: true)
311
311
  offset, length, captures = position
312
- @last_result = Result.new(number: pattern.number, before: @buffer.byteslice(0, offset),
312
+ @last_result = Result.new(status: :matched, match_number: pattern.match_number,
313
+ before: @buffer.byteslice(0, offset),
313
314
  match: @buffer.byteslice(offset, length), after: @buffer.byteslice((offset + length)..),
314
315
  session: self, captures:)
315
316
  if consume
@@ -320,22 +321,28 @@ module Expect
320
321
  end
321
322
  # 诊断回调可能嵌套等待;恢复本次结果后再交给正式模式回调,不能返回内层等待的结果。
322
323
  result = @last_result
323
- trace { "matched pattern #{pattern.number}" }
324
+ trace { "matched pattern #{pattern.match_number}" }
324
325
  @last_result = result
325
326
  end
326
327
 
327
- # 记录超时、EOF 或原始 IO 异常,保留当前缓冲快照并清除旧匹配及捕获组。
328
+ # 记录原始 IO 异常,保留当前缓冲快照并清除旧匹配及捕获组。
328
329
  # @api private
329
330
  def record_error(error)
330
331
  # Result 自行复制冻结文本,无须先通过公开 buffer 再复制一遍。
331
- @last_result = Result.new(error:, before: @buffer, session: self, captures: [])
332
+ @last_result = Result.new(status: :error, error:, before: @buffer, session: self)
333
+ end
334
+
335
+ # 超时是本次等待的正常终止事件,未消费输入继续留在缓冲中。
336
+ # @api private
337
+ def record_timeout
338
+ @last_result = Result.new(status: :timeout, before: @buffer, session: self)
332
339
  end
333
340
 
334
341
  # 输入结束时将剩余缓冲放入 before 并清空,尝试回收但不终止仍活跃的子进程。
335
342
  # @api private
336
343
  def record_eof
337
344
  process_status
338
- record_error(:eof)
345
+ @last_result = Result.new(status: :eof, before: @buffer, session: self)
339
346
  clear_buffer
340
347
  @last_result
341
348
  end
@@ -403,18 +410,8 @@ module Expect
403
410
  @buffer_generation = 0
404
411
  @received_bytes = 0
405
412
  @buffer_discarded_bytes = 0
406
- @outputs = []
407
- @sequences = {}
408
- @literal_prefix_tables = {}
409
- @pending_writes = []
410
- @interact_inputs = {}.compare_by_identity
411
- @interact_output = nil
412
- @interaction_buffer = @relay_owner = @relay_callback = nil
413
- @relay_history = "".b
414
- @relay_history_sequences = {}
415
- @secrets = @transcript_redactor = nil
416
- @diagnostic_redactors = {}
417
- @logger = @transcript = nil
413
+ initialize_interaction
414
+ initialize_logging
418
415
  @last_result = @command = nil
419
416
  @closed = @eof = @receiving = false
420
417
  self.timeout = timeout
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.9.6"
5
+ VERSION = "0.10.0"
6
6
  end
data/sig/expect.rbs CHANGED
@@ -2,8 +2,9 @@
2
2
  module Expect
3
3
  type duration = Numeric
4
4
  type pattern = String | Regexp | :eof | :timeout
5
- type result_error = :eof | :timeout | Exception | nil
6
- type result_fields = { number: Integer?, error: result_error, match: String?, before: String?, after: String?, session: Session?, captures: Array[String?] }
5
+ type result_status = :matched | :timeout | :eof | :error
6
+ type result_error = Exception | nil
7
+ type result_fields = { status: result_status, match_number: Integer?, error: result_error, match: String?, before: String?, after: String?, session: Session?, captures: Array[String?] }
7
8
  type diagnostic_event = { event: Symbol, pid: Integer?, fd: Integer?, message: String }
8
9
 
9
10
  interface _Writer
@@ -189,7 +190,8 @@ module Expect
189
190
 
190
191
  class Result < Data
191
192
  def self.new: (
192
- ?number: Integer?,
193
+ status: result_status,
194
+ ?match_number: Integer?,
193
195
  ?error: result_error,
194
196
  ?match: String?,
195
197
  ?before: String?,
@@ -197,10 +199,11 @@ module Expect
197
199
  ?session: Session?,
198
200
  ?captures: Array[String?]
199
201
  ) -> Result
200
- | (?Integer?, ?result_error, ?String?, ?String?, ?String?, ?Session?, ?Array[String?]) -> Result
202
+ | (result_status, ?Integer?, ?result_error, ?String?, ?String?, ?String?, ?Session?, ?Array[String?]) -> Result
201
203
 
202
204
  def self.[]: (
203
- ?number: Integer?,
205
+ status: result_status,
206
+ ?match_number: Integer?,
204
207
  ?error: result_error,
205
208
  ?match: String?,
206
209
  ?before: String?,
@@ -208,7 +211,7 @@ module Expect
208
211
  ?session: Session?,
209
212
  ?captures: Array[String?]
210
213
  ) -> Result
211
- | (?Integer?, ?result_error, ?String?, ?String?, ?String?, ?Session?, ?Array[String?]) -> Result
214
+ | (result_status, ?Integer?, ?result_error, ?String?, ?String?, ?String?, ?Session?, ?Array[String?]) -> Result
212
215
 
213
216
  def self.members: () -> Array[Symbol]
214
217
 
@@ -216,7 +219,8 @@ module Expect
216
219
 
217
220
  def members: () -> Array[Symbol]
218
221
 
219
- attr_reader number: Integer?
222
+ attr_reader status: result_status
223
+ attr_reader match_number: Integer?
220
224
  attr_reader error: result_error
221
225
  attr_reader match: String?
222
226
  attr_reader before: String?
@@ -225,7 +229,8 @@ module Expect
225
229
  attr_reader captures: Array[String?]
226
230
 
227
231
  def initialize: (
228
- ?number: Integer?,
232
+ status: result_status,
233
+ ?match_number: Integer?,
229
234
  ?error: result_error,
230
235
  ?match: String?,
231
236
  ?before: String?,
@@ -236,6 +241,8 @@ module Expect
236
241
 
237
242
  def matched?: () -> bool
238
243
 
244
+ def error?: () -> bool
245
+
239
246
  def timeout?: () -> bool
240
247
 
241
248
  def eof?: () -> bool
@@ -244,10 +251,11 @@ module Expect
244
251
 
245
252
  def to_s: () -> String
246
253
 
247
- def deconstruct: () -> [Integer?, result_error, String?, String?, String?, Session?, Array[String?]]
254
+ def deconstruct: () -> [result_status, Integer?, result_error, String?, String?, String?, Session?, Array[String?]]
248
255
 
249
256
  def with: (
250
- ?number: Integer?,
257
+ ?status: result_status,
258
+ ?match_number: Integer?,
251
259
  ?error: result_error,
252
260
  ?match: String?,
253
261
  ?before: String?,
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.9.6
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - expect-pty contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-23 00:00:00.000000000 Z
10
+ date: 2026-10-30 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: io-console
@@ -114,7 +114,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
114
114
  requirements:
115
115
  - - ">="
116
116
  - !ruby/object:Gem::Version
117
- version: '3.4'
117
+ version: 4.0.6
118
118
  required_rubygems_version: !ruby/object:Gem::Requirement
119
119
  requirements:
120
120
  - - ">="