reynard 0.5.1 → 0.7.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: 891b142e2ca908078c960db4b36040b407974da43395d02b7ee3f768a1c758b4
4
- data.tar.gz: 5deefbad34c0364cfd3c3a04910b882499c3b96d7c4d642fdb10fbdcc3e9898f
3
+ metadata.gz: bb92e6d5c262256cca1fa813ac8bdb730ab427eeb227dec64475567fe65f4707
4
+ data.tar.gz: dbe5b3b93ac2fd2e702b40f738650bbec0d7feb7ee908b49eab366c2b058723c
5
5
  SHA512:
6
- metadata.gz: 393b2cab7867c45c53b761f42b03e609e653600981aa1283cf3f7c0213379adb9a1eeb1530b3a0f826e07d1ceb5c3fab0d8afc114a2001ec773f71d2484bc109
7
- data.tar.gz: c14ebcc55481c202c0c3c7b7dd36e68eb5b72f0297c0339867e6c298d4a3605284dff2e041d33b074c8c9e3b8064f450fdf481fa686e56c1e764af59a1050593
6
+ metadata.gz: 8ce40599d474dc127b6354f55f8157cccfd26f0d3fc786eb599d4da399a4658a7ce5d3f2505c579e712a38413043f03a5f788f8ac352beb55982dbac4a644134
7
+ data.tar.gz: 79738aae0d48f599f0f123fd0eb9365ce6de15995b9b7208b516d7205ec1bb023d8b7d14d181f82c238fd3415ab5f77ab102fc7593cee2febdd1c915572604cf
data/README.md CHANGED
@@ -79,8 +79,79 @@ response.code #=> '200'
79
79
  response.content_type #=> 'application/json'
80
80
  response['Content-Type'] #=> 'application/json'
81
81
  response.body #=> '{"name":"Sam Seven"}'
82
+ response.parsed_body #=> { "name" => "Sam Seven" }
82
83
  ```
83
84
 
85
+ ## Schema and models
86
+
87
+ Reynard has an object builder that allows you to get a value object backed by model classes based on the resource schema.
88
+
89
+ For example, when the schema for a response is something like this:
90
+
91
+ ```yaml
92
+ book:
93
+ type: object
94
+ properties:
95
+ name:
96
+ type: string
97
+ author:
98
+ type: object
99
+ properties:
100
+ name:
101
+ type: string
102
+ ```
103
+
104
+ And the parsed body from the response is:
105
+
106
+ ```json
107
+ {
108
+ "name": "Erebus",
109
+ "author": { "name": "Palin" }
110
+ }
111
+ ```
112
+
113
+ You should be able to access it using:
114
+
115
+ ```ruby
116
+ response.object.class #=> Reynard::Models::Book
117
+ response.object.author.class #=> Reynard::Models::Author
118
+ response.object.author.name #=> 'Palin'
119
+ ```
120
+
121
+ ### Model name
122
+
123
+ Model names are determined in order:
124
+
125
+ 1. From the `title` attribute of a schema
126
+ 2. From the `$ref` pointing to the schema
127
+ 3. From the path to the definition of the schema
128
+
129
+ ```yaml
130
+ application/json:
131
+ schema:
132
+ $ref: "#/components/schemas/Book"
133
+ components:
134
+ schemas:
135
+ Book:
136
+ type: object
137
+ title: LibraryBook
138
+ ```
139
+
140
+ In this example it would use the `title` and the model name would be `LibraryBook`. Otherwise it would use `Book` from the end of the `$ref`.
141
+
142
+ If neither of those are available it would look at the full expanded path.
143
+
144
+ ```
145
+ books:
146
+ type: array
147
+ items:
148
+ type: object
149
+ ```
150
+
151
+ For example, in case of an array item it would look at `books` and singularize it to `Book`.
152
+
153
+ If you run into issues where Reynard doesn't properly build an object for a nested resource, it's probably because of a naming issue. It's advised to add a `title` property to the schema definition with a unique name in that case.
154
+
84
155
  ## Logging
85
156
 
86
157
  When you want to know what the Reynard client is doing you can enable logging.
@@ -97,6 +168,16 @@ The logging should be compatible with the Ruby on Rails logger.
97
168
  reynard.logger(Rails.logger).execute
98
169
  ```
99
170
 
171
+ ## Debugging
172
+
173
+ You can turn on debug logging in `Net::HTTP` by setting the `DEBUG` environment variable. After setting this, all HTTP interaction will be written to STDERR.
174
+
175
+ ```sh
176
+ env DEBUG=true ruby script.rb
177
+ ```
178
+
179
+ Internally this will set `http.debug_output = $stderr` on the HTTP object in the client.
180
+
100
181
  ## Mocking
101
182
 
102
183
  You can mock Reynard requests by changing the HTTP implementation. The class **must** implement a single `request` method that accepts an URI and net/http request object. It **must** return a net/http response object or an object with the exact same interface.
@@ -25,8 +25,12 @@ class Reynard
25
25
  Net::HTTP.const_get(@request_context.verb.capitalize)
26
26
  end
27
27
 
28
+ def request_headers
29
+ { 'User-Agent' => Reynard.user_agent }.merge(@request_context.headers || {})
30
+ end
31
+
28
32
  def build_request
29
- request = request_class.new(uri, @request_context.headers)
33
+ request = request_class.new(uri, request_headers)
30
34
  if @request_context.body
31
35
  @request_context.logger&.debug { @request_context.body }
32
36
  request.body = @request_context.body
@@ -14,6 +14,38 @@ class Reynard
14
14
  @http_response = http_response
15
15
  end
16
16
 
17
+ # True when the response code is in the 1xx range.
18
+ def informational?
19
+ code.start_with?('1')
20
+ end
21
+
22
+ # True when the response code is in the 2xx range.
23
+ def success?
24
+ code.start_with?('2')
25
+ end
26
+
27
+ # True when the response code is in the 3xx range.
28
+ def redirection?
29
+ code.start_with?('3')
30
+ end
31
+
32
+ # True when the response code is in the 4xx range.
33
+ def client_error?
34
+ code.start_with?('4')
35
+ end
36
+
37
+ # True when the response code is in the 5xx range.
38
+ def server_error?
39
+ code.start_with?('5')
40
+ end
41
+
42
+ # Returns the parsed response body.
43
+ def parsed_body
44
+ return @parsed_body if defined?(@parsed_body)
45
+
46
+ @parsed_body = MultiJson.load(@http_response.body)
47
+ end
48
+
17
49
  # Instantiates an object based on the schema that fits the response.
18
50
  def object
19
51
  return @object if defined?(@object)
@@ -38,9 +70,8 @@ class Reynard
38
70
 
39
71
  def build_object_with_media_type(media_type)
40
72
  ObjectBuilder.new(
41
- media_type: media_type,
42
73
  schema: @specification.schema(media_type.node),
43
- http_response: @http_response
74
+ parsed_body: parsed_body
44
75
  ).call
45
76
  end
46
77
 
data/lib/reynard/http.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Reynard
4
+ # Contains classes that wrap Net::HTTP to provide a slightly more convenient interface.
4
5
  class Http
5
6
  autoload :Request, 'reynard/http/request'
6
7
  autoload :Response, 'reynard/http/response'
@@ -1,21 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Reynard
4
- # Holds node reference and schema name to a media type in the API specification.
4
+ # Holds node reference a media type in the API specification.
5
5
  class MediaType
6
- attr_reader :node, :schema_name
6
+ attr_reader :node
7
7
 
8
- def initialize(node:, schema_name:)
8
+ def initialize(node:)
9
9
  @node = node
10
- @schema_name = schema_name
11
- end
12
-
13
- def media_type
14
- @node[6]
15
- end
16
-
17
- def response_code
18
- @node[4]
19
10
  end
20
11
  end
21
12
  end
data/lib/reynard/model.rb CHANGED
@@ -3,13 +3,18 @@
3
3
  class Reynard
4
4
  # Superclass for dynamic classes generated by the object builder.
5
5
  class Model
6
+ class << self
7
+ # Holds references to the full schema for the model if available.
8
+ attr_accessor :schema
9
+ end
10
+
6
11
  def initialize(attributes)
7
12
  self.attributes = attributes
8
13
  end
9
14
 
10
15
  def attributes=(attributes)
11
16
  attributes.each do |name, value|
12
- instance_variable_set("@#{name}", value)
17
+ instance_variable_set("@#{name}", self.class.cast(name, value))
13
18
  end
14
19
  end
15
20
 
@@ -21,9 +26,18 @@ class Reynard
21
26
  end
22
27
 
23
28
  def respond_to_missing?(attribute_name, *)
24
- !instance_variable_get("@#{attribute_name}").nil?
29
+ instance_variable_defined?("@#{attribute_name}")
25
30
  rescue NameError
26
31
  false
27
32
  end
33
+
34
+ def self.cast(name, value)
35
+ return value unless schema
36
+
37
+ property = schema.property_schema(name)
38
+ return value unless property
39
+
40
+ Reynard::ObjectBuilder.new(schema: property, parsed_body: value).call
41
+ end
28
42
  end
29
43
  end
@@ -5,60 +5,78 @@ require 'ostruct'
5
5
  class Reynard
6
6
  # Defines dynamic classes based on schema and instantiates them for a response.
7
7
  class ObjectBuilder
8
- def initialize(media_type:, schema:, http_response:)
9
- @media_type = media_type
8
+ attr_reader :schema, :parsed_body
9
+
10
+ def initialize(schema:, parsed_body:, model_name: nil)
10
11
  @schema = schema
11
- @http_response = http_response
12
+ @parsed_body = parsed_body
13
+ @model_name = model_name
12
14
  end
13
15
 
14
- def object_class
15
- if @media_type.schema_name
16
- self.class.model_class(@media_type.schema_name, @schema.object_type)
17
- elsif @schema.object_type == 'array'
18
- Array
19
- else
20
- Reynard::Model
21
- end
16
+ def model_name
17
+ @model_name || @schema.model_name
22
18
  end
23
19
 
24
- def item_object_class
25
- if @schema.item_schema_name
26
- self.class.model_class(@schema.item_schema_name, 'object')
27
- else
28
- Reynard::Model
29
- end
20
+ def model_class
21
+ return @model_class if defined?(@model_class)
22
+
23
+ @model_class =
24
+ self.class.model_class_get(model_name) || self.class.model_class_set(model_name, schema)
30
25
  end
31
26
 
32
27
  def call
33
- if @schema.object_type == 'array'
34
- array = object_class.new
35
- data.each { |attributes| array << item_object_class.new(attributes) }
36
- array
28
+ case schema.type
29
+ when 'object'
30
+ model_class.new(parsed_body)
31
+ when 'array'
32
+ cast_array
37
33
  else
38
- object_class.new(data)
34
+ parsed_body
39
35
  end
40
36
  end
41
37
 
42
- def data
43
- @data ||= MultiJson.load(@http_response.body)
38
+ def self.model_class_get(model_name)
39
+ Kernel.const_get("::Reynard::Models::#{model_name}")
40
+ rescue NameError
41
+ nil
44
42
  end
45
43
 
46
- def self.model_class(name, object_type)
47
- model_class_get(name) || model_class_set(name, object_type)
44
+ def self.model_class_set(model_name, schema)
45
+ if schema.type == 'array'
46
+ array_model_class_set(model_name)
47
+ else
48
+ object_model_class_set(model_name, schema)
49
+ end
48
50
  end
49
51
 
50
- def self.model_class_get(name)
51
- Kernel.const_get("::Reynard::Models::#{name}")
52
- rescue NameError
53
- nil
52
+ def self.array_model_class_set(model_name)
53
+ return Array unless model_name
54
+
55
+ ::Reynard::Models.const_set(model_name, Class.new(Array))
54
56
  end
55
57
 
56
- def self.model_class_set(name, object_type)
57
- if object_type == 'array'
58
- Reynard::Models.const_set(name, Class.new(Array))
59
- else
60
- Reynard::Models.const_set(name, Class.new(Reynard::Model))
58
+ def self.object_model_class_set(model_name, schema)
59
+ return Reynard::Model unless model_name
60
+
61
+ model_class = Class.new(Reynard::Model)
62
+ model_class.schema = schema
63
+ ::Reynard::Models.const_set(model_name, model_class)
64
+ end
65
+
66
+ private
67
+
68
+ def cast_array
69
+ return unless parsed_body
70
+
71
+ item_schema = schema.item_schema
72
+ array = model_class.new
73
+ parsed_body.each do |item|
74
+ array << self.class.new(
75
+ schema: item_schema,
76
+ parsed_body: item
77
+ ).call
61
78
  end
79
+ array
62
80
  end
63
81
  end
64
82
  end
@@ -1,14 +1,110 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Reynard
4
- # Holds reference and object type for a schema in the API specification.
4
+ # Holds a references to a schema definition in the specification.
5
5
  class Schema
6
- attr_reader :node, :object_type, :item_schema_name
6
+ attr_reader :node, :namespace
7
7
 
8
- def initialize(node:, object_type:, item_schema_name:)
8
+ def initialize(specification:, node:, namespace: nil)
9
+ @specification = specification
9
10
  @node = node
10
- @object_type = object_type
11
- @item_schema_name = item_schema_name
11
+ @namespace = namespace
12
+ end
13
+
14
+ def type
15
+ return @type if defined?(@type)
16
+
17
+ @type = @specification.dig(*node, 'type')
18
+ end
19
+
20
+ def model_name
21
+ return @model_name if defined?(@model_name)
22
+
23
+ @model_name = find_model_name
24
+ end
25
+
26
+ # Returns the schema for items when the current schema is an array.
27
+ def item_schema
28
+ return unless type == 'array'
29
+
30
+ self.class.new(
31
+ specification: @specification,
32
+ node: [*node, 'items'],
33
+ namespace: [*namespace, model_name]
34
+ )
35
+ end
36
+
37
+ # Returns the schema for a propery in the schema.
38
+ def property_schema(name)
39
+ property_node = [*node, 'properties', name.to_s]
40
+ return unless @specification.dig(*property_node)
41
+
42
+ self.class.new(
43
+ specification: @specification,
44
+ node: property_node,
45
+ namespace: [*namespace, model_name]
46
+ )
47
+ end
48
+
49
+ def self.title_model_name(model_name)
50
+ return unless model_name
51
+
52
+ model_name
53
+ .gsub(/[^[:alpha:]]/, ' ')
54
+ .gsub(/\s{2,}/, ' ')
55
+ .gsub(/(\s+)([[:alpha:]])/) { Regexp.last_match(2).upcase }
56
+ .strip
57
+ end
58
+
59
+ # Extracts a model name from a ref when there is a usable value.
60
+ #
61
+ # ref_model_name("#/components/schemas/Library") => "Library"
62
+ def self.ref_model_name(ref)
63
+ return unless ref
64
+
65
+ normalize_ref_model_name(ref.split('/')&.last)
66
+ end
67
+
68
+ def self.normalize_ref_model_name(model_name)
69
+ # 1. Unescape encoded characters to create an UTF-8 string
70
+ # 2. Remove extensions for regularly used external schema files
71
+ # 3. Replace all non-alphabetic characters with a space (not allowed in Ruby constant)
72
+ # 4. Camelcase
73
+ Rack::Utils.unescape_path(model_name)
74
+ .gsub(/(.yml|.yaml|.json)\Z/, '')
75
+ .gsub(/[^[:alpha:]]/, ' ')
76
+ .gsub(/(\s+)([[:alpha:]])/) { Regexp.last_match(2).upcase }
77
+ .gsub(/\A(.)/) { Regexp.last_match(1).upcase }
78
+ end
79
+
80
+ private
81
+
82
+ # Returns a model name based on the schema's title or $ref.
83
+ def find_model_name
84
+ title_model_name || ref_model_name || node_model_name
85
+ end
86
+
87
+ def title_model_name
88
+ title = @specification.dig(*node, 'title')
89
+ return unless title
90
+
91
+ self.class.title_model_name(title)
92
+ end
93
+
94
+ def ref_model_name
95
+ parent = @specification.dig(*node[..-2])
96
+ ref = parent.dig('schema', '$ref') || parent.dig('items', '$ref')
97
+ return unless ref
98
+
99
+ self.class.ref_model_name(ref)
100
+ end
101
+
102
+ def node_model_name
103
+ self.class.title_model_name(node_property_name.capitalize.gsub(/[_-]/, ' '))
104
+ end
105
+
106
+ def node_property_name
107
+ node.last == 'items' ? node.at(-2).chomp('s') : node.last
12
108
  end
13
109
  end
14
110
  end
@@ -66,10 +66,7 @@ class Reynard
66
66
  response, media_type = media_type_response(responses, response_code, media_type)
67
67
  return unless response
68
68
 
69
- MediaType.new(
70
- node: [*operation_node, 'responses', response_code, 'content', media_type],
71
- schema_name: schema_name(response)
72
- )
69
+ MediaType.new(node: [*operation_node, 'responses', response_code, 'content', media_type])
73
70
  end
74
71
 
75
72
  def media_type_response(responses, response_code, media_type)
@@ -83,14 +80,9 @@ class Reynard
83
80
  end
84
81
 
85
82
  def schema(media_type_node)
86
- schema = dig(*media_type_node, 'schema')
87
- return unless schema
88
-
89
- Schema.new(
90
- node: [*media_type_node, 'schema'],
91
- object_type: schema['type'],
92
- item_schema_name: item_schema_name(schema)
93
- )
83
+ return unless dig(*media_type_node, 'schema')
84
+
85
+ Schema.new(specification: self, node: [*media_type_node, 'schema'])
94
86
  end
95
87
 
96
88
  def self.media_type_matches?(media_type, expression)
@@ -100,26 +92,6 @@ class Reynard
100
92
  false
101
93
  end
102
94
 
103
- def self.normalize_model_name(name)
104
- # 1. Unescape encoded characters to create an UTF-8 string
105
- # 2. Remove extensions for regularly used external schema files
106
- # 3. Replace all non-alphabetic characters with a space (not allowed in Ruby constant)
107
- # 4. Camelcase
108
- Rack::Utils.unescape_path(name)
109
- .gsub(/(.yml|.yaml|.json)\Z/, '')
110
- .gsub(/[^[:alpha:]]/, ' ')
111
- .gsub(/(\s+)([[:alpha:]])/) { Regexp.last_match(2).upcase }
112
- .gsub(/\A(.)/) { Regexp.last_match(1).upcase }
113
- end
114
-
115
- def self.normalize_model_title(title)
116
- title
117
- .gsub(/[^[:alpha:]]/, ' ')
118
- .gsub(/\s{2,}/, ' ')
119
- .gsub(/(\s+)([[:alpha:]])/) { Regexp.last_match(2).upcase }
120
- .strip
121
- end
122
-
123
95
  private
124
96
 
125
97
  def read
@@ -156,27 +128,5 @@ class Reynard
156
128
  # rubocop:enable Metrics/AbcSize
157
129
  # rubocop:enable Metrics/CyclomaticComplexity
158
130
  # rubocop:enable Metrics/MethodLength
159
-
160
- def schema_name(response)
161
- extract_schema_name(response['schema'])
162
- end
163
-
164
- def item_schema_name(schema)
165
- if schema['type'] == 'array'
166
- extract_schema_name(schema['items'])
167
- else
168
- extract_schema_name(schema)
169
- end
170
- end
171
-
172
- def extract_schema_name(definition)
173
- ref = definition['$ref']
174
- return self.class.normalize_model_name(ref&.split('/')&.last) if ref
175
-
176
- title = definition['title']
177
- return unless title
178
-
179
- self.class.normalize_model_title(title)
180
- end
181
131
  end
182
132
  end
@@ -5,7 +5,7 @@ class Reynard
5
5
  #
6
6
  # See: RFC6570
7
7
  class Template
8
- VARIABLE_RE = /\{([^}]+)\}/.freeze
8
+ VARIABLE_RE = /\{([^}]+)\}/
9
9
 
10
10
  def initialize(template, params)
11
11
  @template = template
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Reynard
4
- VERSION = '0.5.1'
4
+ VERSION = '0.7.0'
5
5
  end
data/lib/reynard.rb CHANGED
@@ -42,6 +42,12 @@ class Reynard
42
42
  attr_writer :http
43
43
  end
44
44
 
45
+ # Returns a value that will be used by default for Reynard's User-Agent headers. Please use
46
+ # the +headers+ setter on the context if you want to change this.
47
+ def self.user_agent
48
+ "Reynard/#{Reynard::VERSION}"
49
+ end
50
+
45
51
  # Returns Reynard's global request interface. This is a global object to allow persistent
46
52
  # connections, caching, and other features that need a persistent object in the process.
47
53
  def self.http
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reynard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Manfred Stienstra
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-08-05 00:00:00.000000000 Z
11
+ date: 2023-03-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: multi_json
@@ -52,62 +52,6 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
- - !ruby/object:Gem::Dependency
56
- name: minitest
57
- requirement: !ruby/object:Gem::Requirement
58
- requirements:
59
- - - ">="
60
- - !ruby/object:Gem::Version
61
- version: '0'
62
- type: :development
63
- prerelease: false
64
- version_requirements: !ruby/object:Gem::Requirement
65
- requirements:
66
- - - ">="
67
- - !ruby/object:Gem::Version
68
- version: '0'
69
- - !ruby/object:Gem::Dependency
70
- name: rake
71
- requirement: !ruby/object:Gem::Requirement
72
- requirements:
73
- - - ">="
74
- - !ruby/object:Gem::Version
75
- version: '0'
76
- type: :development
77
- prerelease: false
78
- version_requirements: !ruby/object:Gem::Requirement
79
- requirements:
80
- - - ">="
81
- - !ruby/object:Gem::Version
82
- version: '0'
83
- - !ruby/object:Gem::Dependency
84
- name: webmock
85
- requirement: !ruby/object:Gem::Requirement
86
- requirements:
87
- - - ">="
88
- - !ruby/object:Gem::Version
89
- version: '0'
90
- type: :development
91
- prerelease: false
92
- version_requirements: !ruby/object:Gem::Requirement
93
- requirements:
94
- - - ">="
95
- - !ruby/object:Gem::Version
96
- version: '0'
97
- - !ruby/object:Gem::Dependency
98
- name: webrick
99
- requirement: !ruby/object:Gem::Requirement
100
- requirements:
101
- - - ">="
102
- - !ruby/object:Gem::Version
103
- version: '0'
104
- type: :development
105
- prerelease: false
106
- version_requirements: !ruby/object:Gem::Requirement
107
- requirements:
108
- - - ">="
109
- - !ruby/object:Gem::Version
110
- version: '0'
111
55
  description: |2
112
56
  Reynard is an OpenAPI client for Ruby. It operates directly on the OpenAPI specification without
113
57
  the need to generate any source code.
@@ -143,7 +87,7 @@ licenses:
143
87
  - MIT
144
88
  metadata:
145
89
  rubygems_mfa_required: 'true'
146
- post_install_message:
90
+ post_install_message:
147
91
  rdoc_options: []
148
92
  require_paths:
149
93
  - lib
@@ -151,15 +95,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
151
95
  requirements:
152
96
  - - ">"
153
97
  - !ruby/object:Gem::Version
154
- version: '2.7'
98
+ version: '3.0'
155
99
  required_rubygems_version: !ruby/object:Gem::Requirement
156
100
  requirements:
157
101
  - - ">="
158
102
  - !ruby/object:Gem::Version
159
103
  version: '0'
160
104
  requirements: []
161
- rubygems_version: 3.1.6
162
- signing_key:
105
+ rubygems_version: 3.3.7
106
+ signing_key:
163
107
  specification_version: 4
164
108
  summary: Minimal OpenAPI client.
165
109
  test_files: []