sec_id 6.0.0 → 6.1.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 +7 -0
- data/README.md +45 -0
- data/lib/sec_id/active_model.rb +1 -1
- data/lib/sec_id/base.rb +31 -1
- data/lib/sec_id/detector.rb +3 -26
- data/lib/sec_id/scanner.rb +17 -22
- data/lib/sec_id/version.rb +1 -1
- data/lib/sec_id.rb +2 -3
- data/sig/sec_id/base.rbs +6 -0
- data/sig/sec_id/detector.rbs +0 -6
- data/sig/sec_id/scanner.rbs +2 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2c705053b66b513f6cbac21702cd4548f5f446669f9251db569d62693698ff64
|
|
4
|
+
data.tar.gz: b792e03a67c57ead4064c392925771033c35b68fd984ad93aa5d932e37d1ea21
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 51880f7c27744b9700e01bca80a6f3a9a436d76a843921fdc97a401e12ef0acb2011c4f67a19034c30ea90e59638de0b66cb8a949b32d2d896e28a4f7957c4ba
|
|
7
|
+
data.tar.gz: 780d1cd07415046d2c5dfda3b6f548785ae0e244bdb294876440521f90475562000beaf493c88109d609e9c65fa83b94ad67c8509084174a84e52e6be9268543
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,13 @@ and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [6.1.0] - 2026-07-10
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `SecID::Base#deconstruct_keys` — every identifier now destructures in `case/in` patterns, exposing the same parsed fields `#to_h` reports under `:components`. The `keys` argument is ignored, no new keys are introduced, and validity is not part of the protocol: `SecID.parse` returning `nil` remains the validity guard, and an instance built from unparseable input binds `nil` for each component. No `deconstruct` (array-pattern) method is defined
|
|
16
|
+
- `SecID::Base.type_key` — the memoized class-level registry symbol for an identifier type, joining the existing `short_name` / `full_name` / `id_length` metadata surface. It round-trips through the registry: `SecID[SecID::ISIN.type_key] == SecID::ISIN`. It is now the single authority for that symbol; the registry, `#to_h`, `SecID.explain`, the ActiveModel validator, the detector, and the scanner all read it instead of each deriving it from the class name
|
|
17
|
+
|
|
11
18
|
## [6.0.0] - 2026-07-08
|
|
12
19
|
|
|
13
20
|
### Added
|
data/README.md
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
- [Text Scanning](#text-scanning) - find identifiers in freeform text
|
|
12
12
|
- [Debugging Detection](#debugging-detection) - understand why strings match or don't
|
|
13
13
|
- [Structured Validation](#structured-validation) - detailed error codes and messages
|
|
14
|
+
- [Pattern Matching](#pattern-matching) - destructure identifiers with `case/in`
|
|
14
15
|
- [Generating Test Fixtures](#generating-test-fixtures) - produce valid identifiers for tests
|
|
15
16
|
- [ISIN](#isin) - International Securities Identification Number
|
|
16
17
|
- [CUSIP](#cusip) - Committee on Uniform Securities Identification Procedures
|
|
@@ -75,6 +76,7 @@ All identifier classes provide `valid?`, `errors`, `validate`, `validate!` metho
|
|
|
75
76
|
**All identifiers** support hash serialization:
|
|
76
77
|
- `#to_h` - returns a hash with `:type`, `:full_id`, `:normalized`, `:valid`, and `:components` keys
|
|
77
78
|
- `#as_json` - same as `#to_h`, for JSON serialization compatibility (Rails, `JSON.generate`, etc.)
|
|
79
|
+
- `#deconstruct_keys` - exposes `:components` for [pattern matching](#pattern-matching) with `case/in`
|
|
78
80
|
|
|
79
81
|
```ruby
|
|
80
82
|
SecID::ISIN.new('US5949181045').to_h
|
|
@@ -119,6 +121,7 @@ SecID.identifiers # => [SecID::ISIN, SecID::CUSIP, ...]
|
|
|
119
121
|
SecID.identifiers.map(&:short_name) # => ["ISIN", "CUSIP", "SEDOL", ...]
|
|
120
122
|
|
|
121
123
|
# Query metadata
|
|
124
|
+
SecID::ISIN.type_key # => :isin (SecID[SecID::ISIN.type_key] == SecID::ISIN)
|
|
122
125
|
SecID::ISIN.short_name # => "ISIN"
|
|
123
126
|
SecID::ISIN.full_name # => "International Securities Identification Number"
|
|
124
127
|
SecID::ISIN.id_length # => 12
|
|
@@ -261,6 +264,48 @@ SecID::FIGI.new('BSG000BLNNH6').validate!
|
|
|
261
264
|
isin = SecID::ISIN.validate!('US5949181045') # => #<SecID::ISIN>
|
|
262
265
|
```
|
|
263
266
|
|
|
267
|
+
### Pattern Matching
|
|
268
|
+
|
|
269
|
+
Every identifier destructures in `case/in` via its parsed components. Since `SecID.parse` returns `nil` for
|
|
270
|
+
anything invalid, an `in nil` branch is a complete validity guard:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
case SecID.parse(input)
|
|
274
|
+
in SecID::ISIN[country_code: 'US', nsin:] then "US security #{nsin}"
|
|
275
|
+
in SecID::ISIN[country_code:] then "foreign security from #{country_code}"
|
|
276
|
+
in SecID::OCC[underlying:, type:, strike_mills:] then "#{type} option on #{underlying}"
|
|
277
|
+
in nil then 'not a valid identifier'
|
|
278
|
+
end
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`SecID::Match` is a `Data`, so scan results destructure one layer up and nest into the identifier they wrap:
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
SecID.extract('AAPL ISIN US0378331005').each do |match|
|
|
285
|
+
case match
|
|
286
|
+
in { type: :isin, identifier: SecID::ISIN[country_code: 'US'] } then puts 'US ISIN found'
|
|
287
|
+
else next
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
The keys are exactly the `:components` of `#to_h`, and destructuring never consults
|
|
293
|
+
`valid?`. An instance built directly from unparseable input therefore binds `nil` for every component, the same shape
|
|
294
|
+
`to_h` reports:
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
SecID::ISIN.new('GARBAGE') => { country_code:, nsin: }
|
|
298
|
+
country_code # => nil
|
|
299
|
+
nsin # => nil
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
CIK, WKN, and Valoren have no sub-fields: they match a bare `in SecID::CIK` but no keyed pattern. Array patterns
|
|
303
|
+
(`deconstruct`) are not supported.
|
|
304
|
+
|
|
305
|
+
Watch the `:type` key: on a `SecID::Match` it is the registry symbol (`:occ`), while on an `OCC` instance it is the
|
|
306
|
+
OSI option type (`'C'` or `'P'`), so `in SecID::OCC[type: :occ]` never matches. Other types have no `:type` component
|
|
307
|
+
at all — `#to_h`'s envelope keys (`:type`, `:full_id`, `:normalized`, `:valid`) are not part of the pattern surface.
|
|
308
|
+
|
|
264
309
|
### Generating Test Fixtures
|
|
265
310
|
|
|
266
311
|
Generate syntactically valid identifiers — with correct check digits where applicable — for use as test fixtures. Available per class and via the central dispatcher:
|
data/lib/sec_id/active_model.rb
CHANGED
|
@@ -93,7 +93,7 @@ class SecIdValidator < ActiveModel::EachValidator
|
|
|
93
93
|
#
|
|
94
94
|
# @return [Array<Symbol>]
|
|
95
95
|
def candidate_types
|
|
96
|
-
configured_types || SecID.identifiers.map
|
|
96
|
+
configured_types || SecID.identifiers.map(&:type_key)
|
|
97
97
|
end
|
|
98
98
|
|
|
99
99
|
# sec_id's specific failure reason, only when `details: true` and a single `type:` is set;
|
data/lib/sec_id/base.rb
CHANGED
|
@@ -50,6 +50,22 @@ module SecID
|
|
|
50
50
|
attr_reader :identifier
|
|
51
51
|
|
|
52
52
|
class << self
|
|
53
|
+
# The type's registry symbol: `SecID[SecID::ISIN.type_key] == SecID::ISIN`.
|
|
54
|
+
#
|
|
55
|
+
# @return [Symbol] the registry key (e.g. :isin, :cusip)
|
|
56
|
+
def type_key = @type_key ||= short_name.downcase.to_sym
|
|
57
|
+
|
|
58
|
+
# Composite sort key ranking detection specificity: check-digit types first,
|
|
59
|
+
# then narrower length range, then registration order. A class the registry
|
|
60
|
+
# never saw sorts last.
|
|
61
|
+
#
|
|
62
|
+
# @api private
|
|
63
|
+
# @return [Array] frozen [check-digit rank, length specificity, registration order]
|
|
64
|
+
def detection_priority
|
|
65
|
+
@detection_priority ||=
|
|
66
|
+
[has_check_digit? ? 0 : 1, length_specificity, SecID.identifiers.index(self) || Float::INFINITY].freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
53
69
|
# @return [String] the unqualified class name (e.g. "ISIN", "CUSIP")
|
|
54
70
|
def short_name = @short_name ||= name.split('::').last
|
|
55
71
|
|
|
@@ -114,7 +130,7 @@ module SecID
|
|
|
114
130
|
# @return [Hash] hash with :type, :full_id, :normalized, :valid, and :components keys
|
|
115
131
|
def to_h
|
|
116
132
|
{
|
|
117
|
-
type: self.class.
|
|
133
|
+
type: self.class.type_key,
|
|
118
134
|
full_id: full_id,
|
|
119
135
|
normalized: valid? ? normalized : nil,
|
|
120
136
|
valid: valid?,
|
|
@@ -129,6 +145,20 @@ module SecID
|
|
|
129
145
|
to_h
|
|
130
146
|
end
|
|
131
147
|
|
|
148
|
+
# Exposes the parsed components for `case/in` destructuring. Validity is not part of
|
|
149
|
+
# the protocol: components of unparseable input are `nil`, and `SecID.parse` returning
|
|
150
|
+
# `nil` is the validity guard.
|
|
151
|
+
#
|
|
152
|
+
# @param _keys [Array<Symbol>, nil] the keys the pattern requests; ignored
|
|
153
|
+
# @return [Hash] the parsed components
|
|
154
|
+
#
|
|
155
|
+
# @example
|
|
156
|
+
# case SecID.parse('US5949181045')
|
|
157
|
+
# in SecID::ISIN[country_code:, nsin:] then [country_code, nsin]
|
|
158
|
+
# in nil then :invalid
|
|
159
|
+
# end #=> ['US', '594918104']
|
|
160
|
+
def deconstruct_keys(_keys) = components
|
|
161
|
+
|
|
132
162
|
protected
|
|
133
163
|
|
|
134
164
|
# @return [String]
|
data/lib/sec_id/detector.rb
CHANGED
|
@@ -58,7 +58,7 @@ module SecID
|
|
|
58
58
|
|
|
59
59
|
candidates = filter_candidates(input.upcase)
|
|
60
60
|
matches = candidates.filter_map { |klass| (i = klass.new(input)).valid? ? i : nil }
|
|
61
|
-
matches.min_by { |instance|
|
|
61
|
+
matches.min_by { |instance| instance.class.detection_priority }
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
private
|
|
@@ -81,16 +81,14 @@ module SecID
|
|
|
81
81
|
# @return [Array<Symbol>]
|
|
82
82
|
def validate_and_sort(input, candidates)
|
|
83
83
|
matches = candidates.select { |klass| klass.valid?(input) }
|
|
84
|
-
matches.sort_by!
|
|
85
|
-
matches.map!
|
|
84
|
+
matches.sort_by!(&:detection_priority)
|
|
85
|
+
matches.map!(&:type_key)
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
# @return [void]
|
|
89
89
|
def precompute
|
|
90
90
|
build_discriminator_sets
|
|
91
91
|
build_length_table
|
|
92
|
-
build_priority_table
|
|
93
|
-
build_key_table
|
|
94
92
|
end
|
|
95
93
|
|
|
96
94
|
# Classifies types by which special characters their VALID_CHARS_REGEX accepts.
|
|
@@ -114,27 +112,6 @@ module SecID
|
|
|
114
112
|
@candidates_by_length.each_value(&:freeze)
|
|
115
113
|
end
|
|
116
114
|
|
|
117
|
-
# Builds composite sort keys: check-digit types first, then smaller range, then load order.
|
|
118
|
-
#
|
|
119
|
-
# @return [void]
|
|
120
|
-
def build_priority_table
|
|
121
|
-
@priority_for = {}
|
|
122
|
-
@classes.each_with_index do |klass, index|
|
|
123
|
-
check_digit_rank = klass.has_check_digit? ? 0 : 1
|
|
124
|
-
@priority_for[klass] = [check_digit_rank, klass.length_specificity, index].freeze
|
|
125
|
-
end
|
|
126
|
-
@priority_for.freeze
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# Maps each class to its registry symbol key.
|
|
130
|
-
#
|
|
131
|
-
# @return [void]
|
|
132
|
-
def build_key_table
|
|
133
|
-
@key_for = {}
|
|
134
|
-
@classes.each { |klass| @key_for[klass] = klass.short_name.downcase.to_sym }
|
|
135
|
-
@key_for.freeze
|
|
136
|
-
end
|
|
137
|
-
|
|
138
115
|
# Stage 1: route strings with special characters to the only types that accept them.
|
|
139
116
|
# Returns nil if no special chars found (fall through to stage 2).
|
|
140
117
|
#
|
data/lib/sec_id/scanner.rb
CHANGED
|
@@ -51,12 +51,24 @@ module SecID
|
|
|
51
51
|
private
|
|
52
52
|
|
|
53
53
|
# @return [void]
|
|
54
|
-
def precompute
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
def precompute
|
|
55
|
+
build_candidate_partitions
|
|
56
|
+
build_length_table
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Splits the registered classes by which CANDIDATE_RE group routes to them.
|
|
60
|
+
#
|
|
61
|
+
# @return [void]
|
|
62
|
+
def build_candidate_partitions
|
|
57
63
|
@fisn_classes = @classes.select { |k| k.short_name == 'FISN' }
|
|
58
64
|
@occ_classes = @classes.select { |k| k.short_name == 'OCC' }
|
|
59
65
|
@simple_classes = @classes - @fisn_classes - @occ_classes
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Builds a Hash mapping each possible length to the classes that accept it.
|
|
69
|
+
#
|
|
70
|
+
# @return [void]
|
|
71
|
+
def build_length_table
|
|
60
72
|
@candidates_by_length = Hash.new { |h, k| h[k] = [] }
|
|
61
73
|
@classes.each do |klass|
|
|
62
74
|
klass.length_values.each { |len| @candidates_by_length[len] << klass }
|
|
@@ -64,23 +76,6 @@ module SecID
|
|
|
64
76
|
@candidates_by_length.each_value(&:freeze)
|
|
65
77
|
end
|
|
66
78
|
|
|
67
|
-
# @return [void]
|
|
68
|
-
def build_key_table
|
|
69
|
-
@key_for = {}
|
|
70
|
-
@classes.each { |klass| @key_for[klass] = klass.short_name.downcase.to_sym }
|
|
71
|
-
@key_for.freeze
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# @return [void]
|
|
75
|
-
def build_priority_table
|
|
76
|
-
@priority_for = {}
|
|
77
|
-
@classes.each_with_index do |klass, index|
|
|
78
|
-
check_digit_rank = klass.has_check_digit? ? 0 : 1
|
|
79
|
-
@priority_for[klass] = [check_digit_rank, klass.length_specificity, index].freeze
|
|
80
|
-
end
|
|
81
|
-
@priority_for.freeze
|
|
82
|
-
end
|
|
83
|
-
|
|
84
79
|
# @param input [String]
|
|
85
80
|
# @param target_classes [Array<Class>]
|
|
86
81
|
# @return [void]
|
|
@@ -123,7 +118,7 @@ module SecID
|
|
|
123
118
|
return unless best
|
|
124
119
|
|
|
125
120
|
end_pos = start_pos + raw.length
|
|
126
|
-
Match.new(type:
|
|
121
|
+
Match.new(type: best.type_key, raw: raw, range: start_pos...end_pos, identifier: best.new(cleaned))
|
|
127
122
|
end
|
|
128
123
|
|
|
129
124
|
# @return [Class, nil]
|
|
@@ -134,7 +129,7 @@ module SecID
|
|
|
134
129
|
return if candidates.empty?
|
|
135
130
|
|
|
136
131
|
validated = candidates.select { |k| cleaned.match?(k::VALID_CHARS_REGEX) && k.valid?(cleaned) }
|
|
137
|
-
validated.min_by
|
|
132
|
+
validated.min_by(&:detection_priority)
|
|
138
133
|
end
|
|
139
134
|
end
|
|
140
135
|
end
|
data/lib/sec_id/version.rb
CHANGED
data/lib/sec_id.rb
CHANGED
|
@@ -85,7 +85,7 @@ module SecID
|
|
|
85
85
|
# @return [Hash] hash with :input and :candidates keys
|
|
86
86
|
def explain(str, types: nil)
|
|
87
87
|
input = str.to_s.strip
|
|
88
|
-
target_keys = types || identifier_list.map
|
|
88
|
+
target_keys = types || identifier_list.map(&:type_key)
|
|
89
89
|
candidates = target_keys.map do |key|
|
|
90
90
|
instance = self[key].new(input)
|
|
91
91
|
{ type: key, valid: instance.valid?, errors: instance.errors.details }
|
|
@@ -143,8 +143,7 @@ module SecID
|
|
|
143
143
|
|
|
144
144
|
# @return [void]
|
|
145
145
|
def register_identifier(klass)
|
|
146
|
-
|
|
147
|
-
identifier_map[key] = klass
|
|
146
|
+
identifier_map[klass.type_key] = klass
|
|
148
147
|
identifier_list << klass
|
|
149
148
|
@detector = nil
|
|
150
149
|
@scanner = nil
|
data/sig/sec_id/base.rbs
CHANGED
|
@@ -33,6 +33,9 @@ module SecID
|
|
|
33
33
|
# RBS forbids narrowing an inherited ivar's type.
|
|
34
34
|
def identifier: () -> String?
|
|
35
35
|
|
|
36
|
+
def self.type_key: () -> Symbol
|
|
37
|
+
# `@api private` in YARD, but public here: Detector and Scanner call it from outside.
|
|
38
|
+
def self.detection_priority: () -> Array[Integer | Float | nil]
|
|
36
39
|
def self.short_name: () -> String
|
|
37
40
|
def self.full_name: () -> String
|
|
38
41
|
def self.id_length: () -> (Integer | ::Range[Integer] | ::Array[Integer])
|
|
@@ -49,6 +52,7 @@ module SecID
|
|
|
49
52
|
def hash: () -> Integer
|
|
50
53
|
def to_h: () -> Hash[Symbol, untyped]
|
|
51
54
|
def as_json: (*untyped) -> Hash[Symbol, untyped]
|
|
55
|
+
def deconstruct_keys: (::Array[Symbol]? _keys) -> Hash[Symbol, untyped]
|
|
52
56
|
|
|
53
57
|
# Check-digit surface. Only the types that `include Checkable` implement these
|
|
54
58
|
# (they raise/NoMethodError otherwise); declared on Base so `Generatable#generate`
|
|
@@ -68,5 +72,7 @@ module SecID
|
|
|
68
72
|
@identifier: untyped
|
|
69
73
|
self.@short_name: String
|
|
70
74
|
self.@has_check_digit: bool
|
|
75
|
+
self.@type_key: Symbol
|
|
76
|
+
self.@detection_priority: Array[Integer | Float | nil]
|
|
71
77
|
end
|
|
72
78
|
end
|
data/sig/sec_id/detector.rbs
CHANGED
|
@@ -3,8 +3,6 @@ module SecID
|
|
|
3
3
|
#
|
|
4
4
|
# @api private
|
|
5
5
|
class Detector
|
|
6
|
-
type priority = Array[Integer | Float | nil]
|
|
7
|
-
|
|
8
6
|
def initialize: (Array[singleton(Base)] identifier_list) -> void
|
|
9
7
|
def call: (SecID::input str) -> Array[Symbol]
|
|
10
8
|
def matches?: (SecID::input str) -> bool
|
|
@@ -17,8 +15,6 @@ module SecID
|
|
|
17
15
|
def precompute: () -> void
|
|
18
16
|
def build_discriminator_sets: () -> void
|
|
19
17
|
def build_length_table: () -> void
|
|
20
|
-
def build_priority_table: () -> void
|
|
21
|
-
def build_key_table: () -> void
|
|
22
18
|
def stage1_special_chars: (String upcased) -> Array[singleton(Base)]?
|
|
23
19
|
def stage2_length: (Integer length) -> Array[singleton(Base)]
|
|
24
20
|
def stage3_charset: (String upcased, Array[singleton(Base)] candidates) -> Array[singleton(Base)]
|
|
@@ -29,7 +25,5 @@ module SecID
|
|
|
29
25
|
@space_only_types: Array[singleton(Base)]
|
|
30
26
|
@special_types: Array[singleton(Base)]
|
|
31
27
|
@candidates_by_length: Hash[Integer, Array[singleton(Base)]]
|
|
32
|
-
@priority_for: Hash[singleton(Base), priority]
|
|
33
|
-
@key_for: Hash[singleton(Base), Symbol]
|
|
34
28
|
end
|
|
35
29
|
end
|
data/sig/sec_id/scanner.rbs
CHANGED
|
@@ -28,8 +28,8 @@ module SecID
|
|
|
28
28
|
private
|
|
29
29
|
|
|
30
30
|
def precompute: () -> void
|
|
31
|
-
def
|
|
32
|
-
def
|
|
31
|
+
def build_candidate_partitions: () -> void
|
|
32
|
+
def build_length_table: () -> void
|
|
33
33
|
def scan_text: (String input, Array[singleton(Base)] target_classes) { (Match) -> void } -> void
|
|
34
34
|
def identify_candidate: (untyped match_data, Array[singleton(Base)] target_classes) -> Match?
|
|
35
35
|
def try_classes: (String raw, String cleaned, Integer start_pos, Array[singleton(Base)] classes) -> Match?
|
|
@@ -40,7 +40,5 @@ module SecID
|
|
|
40
40
|
@occ_classes: Array[singleton(Base)]
|
|
41
41
|
@simple_classes: Array[singleton(Base)]
|
|
42
42
|
@candidates_by_length: Hash[Integer, Array[singleton(Base)]]
|
|
43
|
-
@priority_for: Hash[singleton(Base), Array[Integer | Float | nil]]
|
|
44
|
-
@key_for: Hash[singleton(Base), Symbol]
|
|
45
43
|
end
|
|
46
44
|
end
|