sportdb-quick 0.3.1 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d24c209b9b7c40d0f042062a41edf15ebc99a9d6536b06456eb09b365ddc3d11
4
- data.tar.gz: d6d4c961906539774027860d4c29a306186f5fba96b049a3a7b9720a9c87498d
3
+ metadata.gz: d352420645cf51612b5f1c1185f955ac0766d6bb26d84cae1a9e2adc16b2bd1e
4
+ data.tar.gz: fa83e99ca9c5b86ad8f9a101513d537e52338d03e181809579ef06ad03788cff
5
5
  SHA512:
6
- metadata.gz: bde8f1b34c6be7c3c68b5c051dcb21c257d721719ea1e1c648b911c504535832c885395492427ca985de7b2b35e2c2b92590cd5e41b5f5f6c7344dcee8e3904a
7
- data.tar.gz: a46daab3b1d77e498badaca558c237ad43e81c129641efa88450eabaa05d4b71499b6f2d7af0101a46b976e1ab6b0a152a9b3a6c3d0183faae53b56648814e51
6
+ metadata.gz: 3f962bf9735b43bf53aa733c7543679f00daa08582862b4c4624fdc96556d148a2bcb2ea7a16f7a9637bae908cd71591bbc6f0b2fd7b7a0124d3106712a9094b
7
+ data.tar.gz: 818778f17464a71e2e8f7997661a00d4fd0844a5ebffdb22eb7800590f55bf7a7ccbd49aefe7fdd2d325eef84cb4669cdfb1287b5f819d5e5d6aae438c7ae759
data/CHANGELOG.md CHANGED
@@ -1,4 +1,4 @@
1
- ### 0.3.1
1
+ ### 0.5.0
2
2
  ### 0.0.1 / 2024-08-27
3
3
 
4
4
  * Everything is new. First release.
data/Rakefile CHANGED
@@ -21,7 +21,7 @@ Hoe.spec 'sportdb-quick' do
21
21
  self.licenses = ['Public Domain']
22
22
 
23
23
  self.extra_deps = [
24
- ['sportdb-parser', '>= 0.4.0'],
24
+ ['sportdb-parser', '>= 0.5.0'],
25
25
  ['sportdb-structs', '>= 0.5.0'],
26
26
  ['logutils', '>= 0.6.1'],
27
27
  ]
@@ -88,8 +88,6 @@ class MatchParser ## simple match parser for team match schedules
88
88
  @last_round = nil
89
89
  @last_group = nil
90
90
 
91
- ## last_goals - rename to (longer) @last_team_goals or such - why? why not?
92
- @last_goals = 1 ## toggle between 1|2 - hacky (quick & dirty) support for multi-line goals, fix soon!
93
91
 
94
92
  @teams = Hash.new(0) ## track counts (only) for now for (interal) team stats - why? why not?
95
93
  @rounds = {}
@@ -99,12 +97,12 @@ class MatchParser ## simple match parser for team match schedules
99
97
  @warns = [] ## track list of warnings (unmatched lines) too - why? why not?
100
98
 
101
99
 
102
-
103
- @parser = Parser.new
104
100
  @tree = []
105
101
 
106
102
  attrib_found = false
107
103
 
104
+
105
+ lines = []
108
106
  @lines.each_with_index do |line,i|
109
107
 
110
108
  if debug?
@@ -134,6 +132,9 @@ class MatchParser ## simple match parser for team match schedules
134
132
  next
135
133
  end
136
134
 
135
+ lines << line
136
+
137
+ =begin
137
138
  t, error_messages = @parser.parse_with_errors( line )
138
139
 
139
140
 
@@ -151,71 +152,47 @@ class MatchParser ## simple match parser for team match schedules
151
152
  pp t if debug?
152
153
 
153
154
  @tree << t
155
+ =end
154
156
  end # each lines
155
157
 
158
+ txt = lines.join( "\n") + "\n"
159
+ parser = RaccMatchParser.new( txt ) ## use own parser instance (not shared) - why? why not?
160
+ @tree = parser.parse
161
+ ## pp @tree
162
+
156
163
  ## pp @tree
157
164
 
158
165
  ## report parse errors here - why? why not?
159
166
 
160
167
 
161
168
 
162
- @tree.each do |nodes|
169
+ @tree.each do |node|
163
170
 
164
- node_type = nodes[0][0] ## get node type of first/head node
165
-
166
- if node_type == :round_def
171
+ if node.is_a? RaccMatchParser::RoundDef
167
172
  ## todo/fix: add round definition (w begin n end date)
168
173
  ## todo: do not patch rounds with definition (already assume begin/end date is good)
169
174
  ## -- how to deal with matches that get rescheduled/postponed?
170
- parse_round_def( nodes )
171
- elsif node_type == :group_def ## NB: group goes after round (round may contain group marker too)
175
+ on_round_def( node )
176
+ elsif node.is_a? RaccMatchParser::GroupDef ## NB: group goes after round (round may contain group marker too)
172
177
  ### todo: add pipe (|) marker (required)
173
- parse_group_def( nodes )
174
-
175
- elsif node_type == :player ||
176
- node_type == :none # e.g [[:none], [:";"], [:player, "Xhaka"],...]
177
- ## note - for now goals line MUST start with player!!
178
- parse_goals( nodes )
179
- else
180
- ## try to be liberal/flexible
181
- ## eat-up nodes as we go
182
- ## assume match with group / round header
183
- ## etc. on its own line or not
184
-
185
- ## preprocess possible before match nodes
186
-
187
- while !nodes.empty? do
188
- node_type = nodes[0][0] ## get node type of first/head node
189
- if node_type == :round
190
- node = nodes.shift ## eat-up
191
- parse_round_header( node )
192
- elsif node_type == :leg
193
- node = nodes.shift ## eat-up
194
- ## ignore (round) leg for now - add later leg - 1|2|3 etc!!!
195
- ## needs to get added to db/schema too!!!!
196
- ## add @last_leg = nil or 1|2|3 etc.
197
- elsif node_type == :group
198
- ## -- lets you set group e.g. Group A etc.
199
- node = nodes.shift ## eat-up
200
- parse_group_header( node )
201
- elsif node_type == :date
202
- node = nodes.shift ## eat-up
203
- parse_date_header( node )
204
- ## add time here too - why? why not?
205
- ## add skip comma separator here too - why? why not?
206
- ## "slurp-up" in upstream parser?
207
- ## e.g. round, group or group, round ?
208
- else
209
- break
210
- end
211
- end
212
- next if nodes.empty?
213
-
214
- ## rename to try_parse_match - why? why not?
215
- parse_match( nodes )
178
+ on_group_def( node )
179
+ elsif node.is_a? RaccMatchParser::GroupHeader
180
+ on_group_header( node )
181
+ elsif node.is_a? RaccMatchParser::RoundHeader
182
+ on_round_header( node )
183
+ elsif node.is_a? RaccMatchParser::DateHeader
184
+ on_date_header( node )
185
+ elsif node.is_a? RaccMatchParser::MatchLine
186
+ on_match_line( node )
187
+ elsif node.is_a? RaccMatchParser::GoalLine
188
+ on_goal_line( node )
189
+ else
190
+ ## report error
191
+ puts "!! ERROR - unknown node (parse tree type)"
192
+ pp node
193
+ exit 1
216
194
  end
217
-
218
- end # tree.each
195
+ end # tree.each
219
196
 
220
197
  ## note - team keys are names and values are "internal" stats!!
221
198
  ## and NOT team/club/nat_team structs!!
@@ -224,8 +201,8 @@ class MatchParser ## simple match parser for team match schedules
224
201
 
225
202
 
226
203
 
227
- def parse_group_header( node )
228
- logger.debug "parsing group header: >#{node}<"
204
+ def on_group_header( node )
205
+ logger.debug "on group header: >#{node}<"
229
206
 
230
207
  # note: group header resets (last) round (allows, for example):
231
208
  # e.g.
@@ -235,7 +212,7 @@ class MatchParser ## simple match parser for team match schedules
235
212
  # team1 team2 - match (will get new auto-matchday! not last round)
236
213
  @last_round = nil
237
214
 
238
- name = node[1]
215
+ name = node.name
239
216
 
240
217
  group = @groups[ name ]
241
218
  if group.nil?
@@ -248,8 +225,8 @@ class MatchParser ## simple match parser for team match schedules
248
225
  end
249
226
 
250
227
 
251
- def parse_group_def( nodes )
252
- logger.debug "parsing group def: >#{nodes}<"
228
+ def on_group_def( node )
229
+ logger.debug "on group def: >#{node}<"
253
230
 
254
231
  ## e.g
255
232
  ## [:group_def, "Group A"],
@@ -258,26 +235,15 @@ class MatchParser ## simple match parser for team match schedules
258
235
  ## [:team, "Hungary"],
259
236
  ## [:team, "Switzerland"]
260
237
 
261
- node = nodes[0]
262
- name = node[1] ## group name
263
-
264
- teams = nodes[1..-1].map do |node|
265
- if node[0] == :team
266
- team = node[1]
267
- @teams[ team ] += 1
268
- team
269
- else
270
- puts "!! PARSE ERROR - only teams expected in group def; got:"
271
- pp nodes
272
- exit 1
273
- end
274
- end
275
-
238
+ node.teams.each do |team|
239
+ @teams[ team ] += 1
240
+ end
241
+
276
242
  ## todo/check/fix: add back group key - why? why not?
277
- group = Import::Group.new( name: name,
278
- teams: teams )
243
+ group = Import::Group.new( name: node.name,
244
+ teams: node.teams )
279
245
 
280
- @groups[ name ] = group
246
+ @groups[ node.name ] = group
281
247
  end
282
248
 
283
249
 
@@ -307,37 +273,35 @@ class MatchParser ## simple match parser for team match schedules
307
273
  Date.new( y,m,d ) ## y,m,d
308
274
  end
309
275
 
310
- def parse_round_def( nodes )
311
- logger.debug "parsing round def: >#{nodes}<"
276
+
277
+ def on_round_def( node )
278
+ logger.debug "on round def: >#{node}<"
312
279
 
313
280
  ## e.g. [[:round_def, "Matchday 1"], [:duration, "Fri Jun/14 - Tue Jun/18"]]
314
281
  ## [[:round_def, "Matchday 2"], [:duration, "Wed Jun/19 - Sat Jun/22"]]
315
282
  ## [[:round_def, "Matchday 3"], [:duration, "Sun Jun/23 - Wed Jun/26"]]
316
283
 
317
- node = nodes[0]
318
- name = node[1]
284
+ name = node.name
319
285
  # NB: use extracted round name for knockout check
320
286
  # knockout_flag = is_knockout_round?( name )
321
287
 
322
- node = nodes[1]
323
- node_type = node[0]
324
- if node_type == :date
325
- start_date = end_date = _build_date( m: node[2][:m],
326
- d: node[2][:d],
327
- y: node[2][:y],
288
+ if node.date
289
+ start_date = end_date = _build_date( m: node.date[:m],
290
+ d: node.date[:d],
291
+ y: node.date[:y],
328
292
  start: @start)
329
- elsif node_type == :duration
330
- start_date = _build_date( m: node[2][:start][:m],
331
- d: node[2][:start][:d],
332
- y: node[2][:start][:y],
293
+ elsif node.duration
294
+ start_date = _build_date( m: node.duration[:start][:m],
295
+ d: node.duration[:start][:d],
296
+ y: node.duration[:start][:y],
333
297
  start: @start)
334
- end_date = _build_date( m: node[2][:end][:m],
335
- d: node[2][:end][:d],
336
- y: node[2][:end][:y],
298
+ end_date = _build_date( m: node.duration[:end][:m],
299
+ d: node.duration[:end][:d],
300
+ y: node.duration[:end][:y],
337
301
  start: @start)
338
302
  else
339
303
  puts "!! PARSE ERROR - expected date or duration for round def; got:"
340
- pp nodes
304
+ pp node
341
305
  exit 1
342
306
  end
343
307
 
@@ -375,10 +339,11 @@ class MatchParser ## simple match parser for team match schedules
375
339
  end
376
340
 
377
341
 
378
- def parse_round_header( node )
379
- logger.debug "parsing round header: >#{node}<"
342
+ def on_round_header( node )
343
+ logger.debug "on round header: >#{node}<"
380
344
 
381
- name = node[1]
345
+ name = node.names[0] ## ignore more names for now
346
+ ## fix later - fix more names!!!
382
347
 
383
348
  # name = name.sub( ROUND_EXTRA_WORDS_RE, '' )
384
349
  # name = name.strip
@@ -399,12 +364,12 @@ class MatchParser ## simple match parser for team match schedules
399
364
  ## reset date/time e.g. @last_date = nil !!!!
400
365
  end
401
366
 
402
- def parse_date_header( node )
367
+ def on_date_header( node )
403
368
  logger.debug( "date header: >#{node}<")
404
369
 
405
- date = _build_date( m: node[2][:m],
406
- d: node[2][:d],
407
- y: node[2][:y],
370
+ date = _build_date( m: node.date[:m],
371
+ d: node.date[:d],
372
+ y: node.date[:y],
408
373
  start: @start )
409
374
 
410
375
  logger.debug( " date: #{date} with start: #{@start}")
@@ -431,93 +396,17 @@ class MatchParser ## simple match parser for team match schedules
431
396
  =end
432
397
  end
433
398
 
434
- def parse_minutes( nodes )
435
- ## parse goals by player
436
- ## may have multiple minutes!!
437
- goals = []
438
-
439
- node = nodes.shift ## get player
440
- name = node[1]
441
-
442
- loop do
443
- goal = {}
444
- goal[:name] = name
445
-
446
- node_type = nodes[0][0]
447
- if node_type != :minute
448
- puts "!! PARSE ERROR - minute expected to follow player (in goal); got #{node_type}:"
449
- pp nodes
450
- exit 1
451
- end
452
-
453
- node = nodes.shift
454
- goal[:minute] = node[2][:m]
455
- goal[:offset] = node[2][:offset] if node[2][:offset]
456
-
457
- ## check for own goal or penalty or such
458
- if !nodes.empty?
459
- node_type = nodes[0][0]
460
- if node_type == :og
461
- nodes.shift
462
- goal[:og] = true
463
- elsif node_type == :pen
464
- nodes.shift
465
- goal[:pen] = true
466
- else
467
- # do nothing
468
- end
469
- end
470
-
471
- goals << goal
472
-
473
- ## check if another minute ahead; otherwise break
474
- break if nodes.empty?
475
-
476
- node_type = nodes[0][0]
477
399
 
478
- ## Kane 39', 62', 67'
479
- ## consume/eat-up (optional?) commas
480
- if node_type == :','
481
- nodes.shift
482
- node_type = nodes[0][0]
483
- end
400
+ def on_goal_line( node )
401
+ logger.debug "on goal line: >#{node}<"
484
402
 
485
- break if node_type != :minute
486
- end
487
-
488
-
489
- goals
490
- end
491
-
492
-
493
- def parse_goals( nodes )
494
- logger.debug "parse goals: >#{nodes}<"
495
-
496
- goals1 = []
497
- goals2 = []
498
-
499
- while !nodes.empty?
500
- node_type = nodes[0][0]
501
- if node_type == :player
502
- more_goals = parse_minutes( nodes )
503
- ## hacky multi-line support for goals
504
- ## using last_goal (1|2)
505
- @last_goals == 2 ? goals2 += more_goals :
506
- goals1 += more_goals
507
- elsif node_type == :';' ## team separator
508
- nodes.shift # eat-up
509
- @last_goals = 2
510
- elsif node_type == :none
511
- nodes.shift # eat-up
512
- else
513
- puts "!! PARSE ERROR - unexpected node type in goals;; got #{node_type}:"
514
- pp nodes
515
- exit 1
516
- end
517
- end
403
+ goals1 = node.goals1
404
+ goals2 = node.goals2
518
405
 
406
+
519
407
  pp [goals1,goals2] if debug?
520
408
 
409
+
521
410
  ## wrap in struct andd add/append to match
522
411
  =begin
523
412
  class GoalStruct
@@ -532,26 +421,30 @@ class GoalStruct
532
421
 
533
422
  goals = []
534
423
  goals1.each do |rec|
535
- goal = Import::Goal.new(
536
- player: rec[:name],
424
+ rec.minutes.each do |minute|
425
+ goal = Import::Goal.new(
426
+ player: rec.player,
537
427
  team: 1,
538
- minute: rec[:minute],
539
- offset: rec[:offset],
540
- penalty: rec[:pen] || false, # note: pass along/use false NOT nil
541
- owngoal: rec[:og] || false
428
+ minute: minute.m,
429
+ offset: minute.offset,
430
+ penalty: minute.pen || false, # note: pass along/use false NOT nil
431
+ owngoal: minute.og || false
542
432
  )
543
- goals << goal
433
+ goals << goal
434
+ end
544
435
  end
545
436
  goals2.each do |rec|
546
- goal = Import::Goal.new(
547
- player: rec[:name],
437
+ rec.minutes.each do |minute|
438
+ goal = Import::Goal.new(
439
+ player: rec.player,
548
440
  team: 2,
549
- minute: rec[:minute],
550
- offset: rec[:offset],
551
- penalty: rec[:pen] || false, # note: pass along/use false NOT nil
552
- owngoal: rec[:og] || false
441
+ minute: minute.m,
442
+ offset: minute.offset,
443
+ penalty: minute.pen || false, # note: pass along/use false NOT nil
444
+ owngoal: minute.og || false
553
445
  )
554
446
  goals << goal
447
+ end
555
448
  end
556
449
 
557
450
  pp goals if debug?
@@ -569,90 +462,65 @@ class GoalStruct
569
462
  end
570
463
 
571
464
 
572
- def parse_match( nodes )
573
- logger.debug( "parse match: >#{nodes}<" )
465
+ def on_match_line( node )
466
+ logger.debug( "on match: >#{node}<" )
574
467
 
575
468
  ## collect (possible) nodes by type
576
469
  num = nil
470
+ num = node.ord if node.ord
471
+
577
472
  date = nil
578
- time = nil
579
- teams = []
473
+ date = _build_date( m: node.date[:m],
474
+ d: node.date[:d],
475
+ y: node.date[:y],
476
+ start: @start ) if node.date
477
+
478
+ ## note - there's no time (-only) type in ruby
479
+ ## use string (e.g. '14:56', '1:44')
480
+ ## use 01:44 or 1:44 ?
481
+ ## check for 0:00 or 24:00 possible?
482
+ time = ('%d:%02d' % [node.time[:h], node.time[:m]]) if node.time
483
+
484
+
485
+ ### todo/fix
486
+ ## add keywords (e.g. ht, ft or such) to Score.new - why? why not?
487
+ ## or use new Score.build( ht:, ft:, ) or such - why? why not?
488
+ ## pp score
580
489
  score = nil
490
+ if node.score
491
+ ht = node.score[:ht] || [nil,nil]
492
+ ft = node.score[:ft] || [nil,nil]
493
+ et = node.score[:et] || [nil,nil]
494
+ p = node.score[:p] || [nil,nil]
495
+ values = [*ht, *ft, *et, *p]
496
+ ## pp values
497
+ score = Score.new( *values )
498
+ end
499
+
581
500
  more = []
501
+
582
502
  status = nil
503
+ ## if node_type == :status # e.g. awarded, canceled, postponed, etc.
504
+ ## status = node[1]
505
+
583
506
 
584
- while !nodes.empty?
585
- node = nodes.shift
586
- node_type = node[0]
587
-
588
- if node_type == :num
589
- num = node[1]
590
- elsif node_type == :date
591
- ## note: date wipes out/clear time
592
- ## time MUST always come after date
593
- time = nil
594
- date = _build_date( m: node[2][:m],
595
- d: node[2][:d],
596
- y: node[2][:y],
597
- start: @start )
598
- elsif node_type == :time
599
- ## note - there's no time (-only) type in ruby
600
- ## use string (e.g. '14:56', '1:44')
601
- ## use 01:44 or 1:44 ?
602
- ## check for 0:00 or 24:00 possible?
603
- time = '%d:%02d' % [node[2][:h], node[2][:m]]
604
- elsif node_type == :team
605
- teams << node[1]
606
- elsif node_type == :score
607
- ### todo/fix
608
- ## add keywords (e.g. ht, ft or such) to Score.new - why? why not?
609
- ## or use new Score.build( ht:, ft:, ) or such - why? why not?
610
- ht = node[2][:ht] || [nil,nil]
611
- ft = node[2][:ft] || [nil,nil]
612
- et = node[2][:et] || [nil,nil]
613
- p = node[2][:p] || [nil,nil]
614
- values = [*ht, *ft, *et, *p]
615
- ## pp values
616
-
617
- score = Score.new( *values )
618
- ## pp score
619
- elsif node_type == :status # e.g. awarded, canceled, postponed, etc.
620
- status = node[1]
621
- elsif node_type == :vs
622
- ## skip; do nothing
623
- ##
507
+ #
624
508
  ## todo - add ## find (optional) match status e.g. [abandoned] or [replay] or [awarded]
625
509
  ## or [cancelled] or [postponed] etc.
626
510
  ## status = find_status!( line ) ## todo/check: allow match status also in geo part (e.g. after @) - why? why not?
627
-
628
- elsif node_type == :'@' ||
629
- node_type == :',' ||
630
- node_type == :geo ||
631
- node_type == :timezone
511
+ #
512
+ # elsif node_type == :'@' ||
513
+ # node_type == :',' ||
514
+ # node_type == :geo ||
515
+ # node_type == :timezone
632
516
  ## e.g.
633
517
  ## [:"@"], [:geo, "Stade de France"], [:","], [:geo, "Saint-Denis"]]
634
518
  ## [:"@"], [:geo, "Arena de São Paulo"], [:","], [:geo, "São Paulo"], [:timezone, "(UTC-3)"]
635
- more << node[1] if node_type == :geo
636
- else
637
- puts "!! PARSE ERROR - unexpected node type #{node_type} in match line; got:"
638
- pp node
639
- ## exit 1
640
- @errors << ["PARSE ERROR - unexpected node type #{node_type} in match line; got: #{node.inspect}"]
641
- return
642
- end
643
- end
644
-
519
+ # more << node[1] if node_type == :geo
520
+
645
521
 
646
- if teams.size != 2
647
- puts "!! PARSE ERROR - expected two teams; got #{teams.size}:"
648
- pp teams
649
- ## exit 1
650
- @errors << ["PARSE ERROR - expected two teams; got #{teams.size}: #{teams.inspect}"]
651
- return
652
- end
653
-
654
- team1 = teams[0]
655
- team2 = teams[1]
522
+ team1 = node.team1
523
+ team2 = node.team2
656
524
 
657
525
  @teams[ team1 ] += 1
658
526
  @teams[ team2 ] += 1
@@ -738,10 +606,6 @@ class GoalStruct
738
606
  status: status,
739
607
  ground: ground )
740
608
  ### todo: cache team lookups in hash?
741
-
742
- ## hacky goals support
743
- ### reset/toggle 1/2
744
- @last_goals = 1
745
609
  end
746
610
  end # class MatchParser
747
611
  end # module SportDb
@@ -3,8 +3,8 @@ module SportDb
3
3
  module Module
4
4
  module Quick
5
5
  MAJOR = 0 ## todo: namespace inside version or something - why? why not??
6
- MINOR = 3
7
- PATCH = 1
6
+ MINOR = 5
7
+ PATCH = 0
8
8
  VERSION = [MAJOR,MINOR,PATCH].join('.')
9
9
 
10
10
  def self.version
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sportdb-quick
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gerald Bauer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-01-02 00:00:00.000000000 Z
11
+ date: 2025-01-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sportdb-parser
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 0.4.0
19
+ version: 0.5.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: 0.4.0
26
+ version: 0.5.0
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: sportdb-structs
29
29
  requirement: !ruby/object:Gem::Requirement