ctx_parser 0.1.1

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.
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_catalog"
4
+
5
+ module CtxParser
6
+ # 组合平台命令策略,仅覆盖已确认的型号差异。
7
+ class Profile
8
+ COMMANDS = {}.freeze
9
+ PARSERS = {}.freeze
10
+ MODEL_PARSERS = {}.freeze
11
+
12
+ attr_reader :model
13
+
14
+ # 根据传入设备型号选择命令与解析行为。
15
+ def initialize(model: nil)
16
+ raise ArgumentError, "Device model must be text" unless model.nil? || model.is_a?(String)
17
+
18
+ @model = model&.strip&.downcase&.freeze
19
+ @model_parsers = model_parsers
20
+ end
21
+
22
+ # 返回当前平台策略声明的命令目录。
23
+ def commands = self.class::COMMANDS
24
+
25
+ # 动态目录可替换解析器索引,同时保留型号特定选择。
26
+ def parsers = self.class::PARSERS
27
+
28
+ # 保留选择顺序并归组共享视图,不混用不同命令的正文。
29
+ def queries(names)
30
+ unless names.is_a?(Array) && names.uniq == names && (names - commands.keys).empty?
31
+ raise ArgumentError, "Query dimensions must be unique supported names"
32
+ end
33
+
34
+ plan = names.each_with_object({}) do |name, queries|
35
+ commands_for(name).each { |command| (queries[command] ||= []) << name }
36
+ end
37
+ plan.each_value(&:freeze)
38
+ plan.freeze
39
+ end
40
+
41
+ # 单个维度可能需要多个独立回显;调用方必须分别保存正文。
42
+ def commands_for(name)
43
+ declaration = commands.fetch(name) { raise ArgumentError, "Unsupported parser dimension" }
44
+ queries = declaration.is_a?(Array) ? declaration : [ declaration ]
45
+ unless queries.any? && queries.uniq == queries && queries.all? { |query| query.is_a?(String) && !query.strip.empty? && !/[\x00-\x1f\x7f]/.match?(query) }
46
+ raise ArgumentError, "Parser dimension must declare one or more unique commands"
47
+ end
48
+
49
+ queries.map { |query| query.frozen? ? query : query.dup.freeze }.freeze
50
+ end
51
+
52
+ # 解析支持的观测维度对应的命令。
53
+ def command(name)
54
+ queries = commands_for(name)
55
+ raise ArgumentError, "Parser dimension has multiple commands; select one explicitly" unless queries.one?
56
+
57
+ queries.first
58
+ end
59
+
60
+ # 确定维度使用的解析器,包括命令特定变体。
61
+ def parser_for(name, command: nil)
62
+ query = select_command([ name ], command)
63
+ @model_parsers.fetch(query) { parsers.fetch(query) }
64
+ end
65
+
66
+ # 选择目标命令解析器,并投影单个请求维度。
67
+ def parse(name, text, complete: false, command: nil)
68
+ parse_many([ name ], text, complete: complete, command: command).fetch(name)
69
+ end
70
+
71
+ # AP、Radio 等共享视图仅解析一次,确保状态与数量证据一致。
72
+ def parse_many(names, text, complete: false, command: nil)
73
+ unless names.is_a?(Array) && names.any? && names.uniq == names
74
+ raise ArgumentError, "Parser dimensions must be a nonempty unique array"
75
+ end
76
+ query = select_command(names, command)
77
+
78
+ result = parser_for(names.first, command: query).parse(text, complete: complete)
79
+ names.to_h { |name| [ name, project_independently(name, result) ] }
80
+ end
81
+
82
+ private
83
+
84
+ # 仅当存在唯一公共查询时允许推断;显式命令必须覆盖全部请求视图。
85
+ def select_command(names, selected)
86
+ candidates = names.map { |name| commands_for(name) }.reduce { |common, queries| common & queries }
87
+ unless selected.nil?
88
+ raise ArgumentError, "Selected command does not provide all parser dimensions" unless candidates.include?(selected)
89
+
90
+ selected
91
+ else
92
+ raise ArgumentError, "One CLI output must belong to one command" unless candidates.one?
93
+
94
+ candidates.first
95
+ end
96
+ end
97
+
98
+ # 采集前校验所选型号,并隔离后续修改对型号查找的影响。
99
+ def model_parsers
100
+ declarations = self.class::MODEL_PARSERS
101
+ raise ArgumentError, "Model parsers must be a hash" unless declarations.is_a?(Hash)
102
+
103
+ overrides = declarations.fetch(model, {})
104
+ unless overrides.is_a?(Hash) && overrides.all? do |command, parser|
105
+ self.class::PARSERS.key?(command) && parser.is_a?(Class) && parser < Parser
106
+ end
107
+ raise ArgumentError, "Model parsers must override declared commands with Parser subclasses"
108
+ end
109
+
110
+ overrides.dup.freeze
111
+ end
112
+
113
+ # 投影失败仅影响单个视图;命令选择及解析错误仍正常抛出。
114
+ def project_independently(name, result)
115
+ return failed_projection(:invalid_parser_result) unless result.is_a?(Result) || result.is_a?(ResultSet)
116
+
117
+ projected = project(name, result)
118
+ projected.is_a?(Result) ? projected : failed_projection(:invalid_parser_result)
119
+ rescue KeyError
120
+ code = result.is_a?(ResultSet) && !result.results.key?(name) ? :missing_parser_view : :parser_projection_failed
121
+ failed_projection(code)
122
+ rescue StandardError
123
+ failed_projection(:parser_projection_failed)
124
+ end
125
+
126
+ # 诊断代码描述边界失败,不保留异常消息或原始值。
127
+ def failed_projection(code) = Result.new(status: :failed, issues: [ { code: code } ])
128
+
129
+ # 从共享命令结果中提取请求维度。
130
+ def project(name, result)
131
+ result.is_a?(ResultSet) ? result.fetch(name) : result
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CtxParser
4
+ # 检测身份冲突,不附加厂商特定的保留或完整性策略。
5
+ class Records
6
+ attr_reader :rows, :first_occurrences, :unambiguous, :duplicate_identity_count, :duplicate_row_count
7
+
8
+ # 冻结筛选结果数组,记录值的所有权仍属于调用方。
9
+ def initialize(rows, identity: nil, &key)
10
+ unless rows.is_a?(Array) && rows.all? { |row| row.is_a?(Hash) }
11
+ raise ArgumentError, "Records must be hashes"
12
+ end
13
+
14
+ key ||= identity if identity.respond_to?(:call)
15
+ key ||= ->(row) { row.values_at(*identity) } if identity.is_a?(Array) && identity.any?
16
+ raise ArgumentError, "Identity must be a nonempty array or callable" unless key
17
+
18
+ @rows = rows.dup.freeze
19
+ groups = @rows.group_by { |row| key.call(row) }.values
20
+ @first_occurrences = groups.map(&:first).freeze
21
+ @unambiguous = groups.filter_map { |group| group.first if group.one? }.freeze
22
+ @duplicate_identity_count = groups.count { |group| group.size > 1 }
23
+ @duplicate_row_count = @rows.size - groups.size
24
+ freeze
25
+ end
26
+
27
+ # 判断是否有多条记录声明相同逻辑身份。
28
+ def duplicates? = duplicate_identity_count.positive?
29
+ # 序列化诊断元数据,不包含原始来源文本。
30
+ def as_json(*) = { rows: rows.size, duplicate_identities: duplicate_identity_count }
31
+
32
+ # 提供身份统计,不记录可能包含敏感信息的记录正文。
33
+ def inspect = "#<#{self.class.name} size=#{rows.size} duplicate_identities=#{duplicate_identity_count}>"
34
+ alias to_s inspect
35
+ end
36
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "profile"
4
+
5
+ module CtxParser
6
+ # 各领域独立持有注册表;不支持的平台直接拒绝,不猜测其他厂商语法。
7
+ class Registry
8
+ # 注册平台策略及其声明的别名。
9
+ def initialize(profiles:, aliases: {})
10
+ @profiles = profiles.transform_keys { |key| platform_key(key) }.freeze
11
+ @aliases = aliases.transform_keys { |key| platform_key(key) }.transform_values { |key| platform_key(key) }.freeze
12
+ unless @profiles.size == profiles.size && @aliases.size == aliases.size &&
13
+ @profiles.values.all? { |profile| profile.is_a?(Class) && profile < Profile } &&
14
+ @aliases.values.all? { |key| @profiles.key?(key) } && (@profiles.keys & @aliases.keys).empty?
15
+ raise ArgumentError, "Invalid parser registry"
16
+ end
17
+ end
18
+
19
+ # 解析请求的解析结果或平台策略,拒绝未知名称。
20
+ def fetch(platform, model: nil)
21
+ key = platform_key(platform)
22
+ key = @aliases.fetch(key, key)
23
+ profile = @profiles.fetch(key) { raise ArgumentError, "Unsupported parser platform" }
24
+ profile.new(model: model)
25
+ end
26
+
27
+ private
28
+
29
+ # 选择已注册平台策略前,先归一化平台别名。
30
+ def platform_key(value)
31
+ raise ArgumentError, "Parser platform must be a name" unless value.is_a?(String) || value.is_a?(Symbol)
32
+
33
+ key = value.to_s.strip.downcase
34
+ raise ArgumentError, "Parser platform must be a name" if key.empty?
35
+
36
+ key.to_sym
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "immutable"
4
+
5
+ module CtxParser
6
+ # 仅保存解析结果;采集命令、时间与证据由调用方管理。
7
+ Result = Data.define(:status, :records, :issues) do
8
+ # 冻结解析状态、归一化记录与诊断证据。
9
+ def initialize(status:, records: [], issues: [])
10
+ raise ArgumentError, "Invalid parser status" unless %i[complete partial failed unsupported].include?(status)
11
+ raise ArgumentError, "Records must be hashes" unless records.is_a?(Array) && records.all? { |row| row.is_a?(Hash) }
12
+
13
+ super(status: status, records: Immutable.copy(records), issues: Immutable.copy(issues))
14
+ end
15
+
16
+ # 判断解析结果是否具有完整证据。
17
+ def complete? = status == :complete
18
+
19
+ # 展示对象身份与状态,避免泄露原始回显或凭据。
20
+ def inspect = "#<#{self.class.name} status=#{status} records=#{records.size}>"
21
+ end
22
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "result"
4
+
5
+ module CtxParser
6
+ # 同一回显可产生多份清单,各自保留完整性与诊断。
7
+ ResultSet = Data.define(:results) do
8
+ # 冻结具名章节结果,同时保留各章节的独立状态。
9
+ def initialize(results:)
10
+ unless results.is_a?(Hash) && results.any? && results.values.all? { |result| result.is_a?(Result) }
11
+ raise ArgumentError, "Result set must contain parser results"
12
+ end
13
+ super(results: results.dup.freeze)
14
+ end
15
+
16
+ # 返回必需的具名章节结果。
17
+ def fetch(name) = results.fetch(name)
18
+
19
+ # 判断所有已选事实是否都有完整证据支持。
20
+ def complete? = results.values.all?(&:complete?)
21
+
22
+ # 推导汇总状态,避免将证据缺失当作成功。
23
+ def status
24
+ return :complete if complete?
25
+ return :partial if results.values.any? { |result| %i[complete partial].include?(result.status) }
26
+
27
+ results.values.all? { |result| result.status == :unsupported } ? :unsupported : :failed
28
+ end
29
+
30
+ # 从全部章节结果汇总去重后的诊断。
31
+ def issues = results.values.flat_map(&:issues).uniq
32
+
33
+ # 展示对象身份与状态,避免泄露原始回显或凭据。
34
+ def inspect = "#<#{self.class.name} status=#{status} sections=#{results.keys.join(',')}>"
35
+ end
36
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module CtxParser
6
+ # 严格转换标量;单位及可选字段语法仍由命令解析器负责。
7
+ module Values
8
+ class OutOfRange < ArgumentError; end
9
+
10
+ module_function
11
+
12
+ # 解析十进制整数,并校验声明的边界。
13
+ def integer(raw, min: 0, max: nil)
14
+ return if raw.nil?
15
+ raise ArgumentError, "Invalid decimal integer" unless /\A\d+\z/.match?(raw.to_s)
16
+
17
+ value = Integer(raw.to_s, 10)
18
+ raise OutOfRange, "Integer out of range" if (min && value < min) || (max && value > max)
19
+
20
+ value
21
+ end
22
+
23
+ # 归一化 MAC 地址,拒绝格式错误的输入。
24
+ def mac(raw)
25
+ return if raw.nil?
26
+ unless /\A(?:[0-9a-f]{12}|(?:[0-9a-f]{4}[-.]){2}[0-9a-f]{4}|(?:[0-9a-f]{2}:){5}[0-9a-f]{2})\z/i.match?(raw)
27
+ raise ArgumentError, "Invalid MAC address"
28
+ end
29
+
30
+ raw.delete("-.:").downcase.scan(/../).join(":")
31
+ end
32
+
33
+ # 归一化主机地址,保留 IPv4 与 IPv6 语义。
34
+ def host(raw, allow_zone: false)
35
+ return if raw.nil?
36
+ raise ArgumentError, "Invalid host address" if raw.include?("/") || (!allow_zone && raw.include?("%"))
37
+
38
+ IPAddr.new(raw).to_s
39
+ end
40
+
41
+ # 校验主机地址有效性,不让格式错误值继续传播。
42
+ def valid_host?(raw, allow_zone: false)
43
+ !host(raw, allow_zone: allow_zone).nil?
44
+ rescue ArgumentError, IPAddr::Error
45
+ false
46
+ end
47
+
48
+ # 统一校验网段与接口地址,仅接口投影保留主机位。
49
+ def ipv4_prefix(raw, host: false)
50
+ unless raw.is_a?(String) && raw.match?(%r{\A\d+(?:\.\d+){3}/\d+\z})
51
+ raise ArgumentError, "IPv4 prefix required"
52
+ end
53
+ network = IPAddr.new(raw)
54
+ raise ArgumentError, "IPv4 prefix required" unless network.ipv4?
55
+
56
+ host ? raw : "#{network}/#{network.prefix}"
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CtxParser
4
+ # 公开包版本,供 RubyGems 元数据和运行时调用方共用。
5
+ VERSION = "0.1.1"
6
+ end
data/lib/ctx_parser.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ctx_parser/version"
4
+
5
+ # 加载可能被独立自动加载的依赖前,先定义命名空间。
6
+ module CtxParser
7
+ end
8
+
9
+ require_relative "ctx_parser/parser"
10
+ if CtxParser.const_defined?(:Parser, false)
11
+ require_relative "ctx_parser/configuration_parser"
12
+ require_relative "ctx_parser/values"
13
+ require_relative "ctx_parser/records"
14
+ require_relative "ctx_parser/command_catalog"
15
+ require_relative "ctx_parser/profile"
16
+ require_relative "ctx_parser/registry"
17
+ end
metadata ADDED
@@ -0,0 +1,99 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ctx_parser
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Gatework contributors
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: digest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ipaddr
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: Reusable configuration documents, command catalogs, platform profiles,
41
+ immutable results and parsing primitives for network automation.
42
+ executables: []
43
+ extensions: []
44
+ extra_rdoc_files: []
45
+ files:
46
+ - CHANGELOG.md
47
+ - LICENSE
48
+ - README-zh.md
49
+ - README.md
50
+ - docs/architecture-zh.md
51
+ - docs/release.md
52
+ - examples/offline.rb
53
+ - lib/ctx_parser.rb
54
+ - lib/ctx_parser/banner.rb
55
+ - lib/ctx_parser/block.rb
56
+ - lib/ctx_parser/capture.rb
57
+ - lib/ctx_parser/command_catalog.rb
58
+ - lib/ctx_parser/configuration_parser.rb
59
+ - lib/ctx_parser/cursor.rb
60
+ - lib/ctx_parser/document.rb
61
+ - lib/ctx_parser/immutable.rb
62
+ - lib/ctx_parser/input.rb
63
+ - lib/ctx_parser/line.rb
64
+ - lib/ctx_parser/parser.rb
65
+ - lib/ctx_parser/profile.rb
66
+ - lib/ctx_parser/records.rb
67
+ - lib/ctx_parser/registry.rb
68
+ - lib/ctx_parser/result.rb
69
+ - lib/ctx_parser/result_set.rb
70
+ - lib/ctx_parser/values.rb
71
+ - lib/ctx_parser/version.rb
72
+ homepage: https://github.com/gatework/ctx_parser
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ source_code_uri: https://github.com/gatework/ctx_parser
77
+ bug_tracker_uri: https://github.com/gatework/ctx_parser/issues
78
+ changelog_uri: https://github.com/gatework/ctx_parser/blob/main/CHANGELOG.md
79
+ documentation_uri: https://github.com/gatework/ctx_parser#readme
80
+ allowed_push_host: https://rubygems.org
81
+ rubygems_mfa_required: 'true'
82
+ rdoc_options: []
83
+ require_paths:
84
+ - lib
85
+ required_ruby_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: 4.0.6
90
+ required_rubygems_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ requirements: []
96
+ rubygems_version: 4.0.16
97
+ specification_version: 4
98
+ summary: Parse network CLI evidence into normalized facts.
99
+ test_files: []