hermes-client 0.0.0 → 0.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.
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Require "http/cookie" explicitly before requiring "http", to avoid a
4
+ # "circular dependency" warning due to the way the "http" gem uses autoload.
5
+ require "http/cookie"
6
+ require "http"
7
+ require "json"
8
+
9
+ require "hermes_agent/client/util"
10
+
11
+ module HermesAgent
12
+ class Client
13
+ ##
14
+ # The single chokepoint for HTTP communication with the server.
15
+ #
16
+ # Transport owns the underlying `http` gem connection, attaches the
17
+ # `Authorization` header, serializes and parses JSON, and maps non-2xx
18
+ # responses to the {Error} hierarchy. Resource objects build paths and
19
+ # delegate here.
20
+ #
21
+ # The connection is **persistent and scoped to the transport instance**: a
22
+ # single keep-alive `HTTP::Session`, built lazily on first use, is reused
23
+ # across every request so the TCP/TLS handshake happens once rather than per
24
+ # call. The `http` gem transparently reopens the connection when it has been
25
+ # closed by the server, has exceeded its keep-alive lifetime, or a prior
26
+ # request failed (timeout or socket error), so callers never manage the
27
+ # connection lifecycle. Because the session is per-instance and holds live
28
+ # connection state, a transport — like the {Client} that owns it — is **not
29
+ # thread-safe**; multithreaded callers should use a separate client per
30
+ # thread.
31
+ #
32
+ # @private
33
+ class Transport
34
+ ##
35
+ # The outcome of a request whose response headers matter to the caller:
36
+ # the parsed body — or, for a streaming request, the live chunk
37
+ # enumerator — paired with the response headers as a Hash with downcased
38
+ # string keys. Returned by {#post} and {#stream_post}; the header-agnostic
39
+ # {#get} and {#delete} return the bare parsed body instead.
40
+ #
41
+ # @!attribute [r] body
42
+ # @return [Hash, Enumerator] The parsed JSON body, or the chunk
43
+ # enumerator for a streaming request.
44
+ # @!attribute [r] headers
45
+ # @return [Hash{String=>String}] The response headers, keyed by
46
+ # downcased name.
47
+ #
48
+ Result = ::Data.define(:body, :headers)
49
+
50
+ ##
51
+ # Create a transport.
52
+ #
53
+ # @param config [Configuration] The connection settings to use.
54
+ #
55
+ def initialize(config)
56
+ @config = config
57
+ end
58
+
59
+ ##
60
+ # Issue a GET request and return the parsed JSON body.
61
+ #
62
+ # @param path [String] The request path, including any prefix such as
63
+ # `/v1` or `/health` (resources own their prefixes).
64
+ # @return [Hash] The parsed response body, with string keys.
65
+ # @raise [APIError] If the server returns a non-2xx response.
66
+ #
67
+ def get(path)
68
+ response = map_request_errors { session.get(url_for(path)) }
69
+ handle(response)
70
+ end
71
+
72
+ ##
73
+ # Issue a POST request with a JSON body and return the parsed JSON body.
74
+ #
75
+ # @param path [String] The request path, including any prefix such as
76
+ # `/v1` (resources own their prefixes).
77
+ # @param body [Hash] The request body, serialized to JSON. The
78
+ # `Content-Type: application/json` header is set automatically.
79
+ # @param headers [Hash, nil] Extra request headers to send, merged over
80
+ # the defaults (e.g. the session-continuity headers).
81
+ # @return [Result] The parsed response body and the response headers.
82
+ # @raise [APIError] If the server returns a non-2xx response.
83
+ #
84
+ def post(path, body, headers: nil)
85
+ response = map_request_errors { session.post(url_for(path), json: body, headers: headers) }
86
+ Result.new(body: handle(response), headers: normalize_headers(response.headers))
87
+ end
88
+
89
+ ##
90
+ # Issue a PATCH request with a JSON body and return the parsed JSON body.
91
+ #
92
+ # Like {#get} and {#delete}, this returns the bare parsed body rather than
93
+ # a {Result}: no PATCH endpoint surfaces response headers the caller needs.
94
+ #
95
+ # @param path [String] The request path, including any prefix such as
96
+ # `/v1` or `/api` (resources own their prefixes).
97
+ # @param body [Hash] The request body, serialized to JSON. The
98
+ # `Content-Type: application/json` header is set automatically.
99
+ # @return [Hash] The parsed response body, with string keys.
100
+ # @raise [APIError] If the server returns a non-2xx response.
101
+ #
102
+ def patch(path, body)
103
+ response = map_request_errors { session.patch(url_for(path), json: body) }
104
+ handle(response)
105
+ end
106
+
107
+ ##
108
+ # Issue a DELETE request and return the parsed JSON body.
109
+ #
110
+ # @param path [String] The request path, including any prefix such as
111
+ # `/v1` (resources own their prefixes).
112
+ # @return [Hash] The parsed response body, with string keys.
113
+ # @raise [APIError] If the server returns a non-2xx response.
114
+ #
115
+ def delete(path)
116
+ response = map_request_errors { session.delete(url_for(path)) }
117
+ handle(response)
118
+ end
119
+
120
+ ##
121
+ # Open a streaming POST and return its live response body for an SSE
122
+ # consumer ({Stream}). The response status is checked up front, so an
123
+ # error response raises before any streaming begins; on success the body
124
+ # is returned unread so it can be consumed incrementally.
125
+ #
126
+ # @param path [String] The request path, including its prefix.
127
+ # @param body [Hash] The request body, serialized to JSON.
128
+ # @param headers [Hash, nil] Extra request headers to send, merged over
129
+ # the defaults (e.g. the session-continuity headers).
130
+ # @return [Result] The live chunk enumerator (as `body`) and the response
131
+ # headers. Iterate `body` with `#each` to read byte chunks as they
132
+ # arrive.
133
+ # @raise [APIError] If the server returns a non-2xx response.
134
+ #
135
+ def stream_post(path, body, headers: nil)
136
+ response = map_request_errors { session.post(url_for(path), json: body, headers: headers) }
137
+ unless response.status.success?
138
+ raise APIError.from_response(status: response.code, body: response.body.to_s,
139
+ headers: normalize_headers(response.headers))
140
+ end
141
+ Result.new(body: map_stream_errors(response.body), headers: normalize_headers(response.headers))
142
+ end
143
+
144
+ ##
145
+ # Open a streaming GET and return its live response body for an SSE
146
+ # consumer ({Stream}). Like {#stream_post}, the status is checked up front
147
+ # so an error response raises before any streaming begins; on success the
148
+ # body is returned unread for incremental consumption. The response
149
+ # headers are not surfaced (this is the streaming counterpart of {#get},
150
+ # which also returns the bare body).
151
+ #
152
+ # @param path [String] The request path, including its prefix.
153
+ # @return [Enumerator] The live chunk enumerator. Iterate it with `#each`
154
+ # to read byte chunks as they arrive.
155
+ # @raise [APIError] If the server returns a non-2xx response.
156
+ #
157
+ def stream_get(path)
158
+ response = map_request_errors { session.get(url_for(path)) }
159
+ unless response.status.success?
160
+ raise APIError.from_response(status: response.code, body: response.body.to_s,
161
+ headers: normalize_headers(response.headers))
162
+ end
163
+ map_stream_errors(response.body)
164
+ end
165
+
166
+ private
167
+
168
+ ##
169
+ # Wrap a streaming response body so transport-level failures hit while
170
+ # reading it are mapped to the {Error} hierarchy.
171
+ #
172
+ # The body is read lazily, chunk by chunk, *after* {#stream_post} has
173
+ # returned — so a socket or read-timeout failure mid-stream happens
174
+ # outside `map_request_errors`'s rescue and would otherwise surface as the raw
175
+ # `http`-gem exception. Iterating the returned enumerator re-reads the
176
+ # body inside `map_request_errors`, so the same {TimeoutError}/{ConnectionError}
177
+ # mapping applies. Chunks delivered before the failure are still yielded;
178
+ # the exception is raised when the failing read is reached. Keeps {Stream}
179
+ # HTTP-agnostic — it only ever sees mapped errors.
180
+ #
181
+ # @param body [#each] The live response body, yielding String chunks.
182
+ # @return [Enumerator] The same chunks, with mid-stream failures mapped.
183
+ #
184
+ def map_stream_errors(body)
185
+ ::Enumerator.new do |yielder|
186
+ map_request_errors { body.each { |chunk| yielder << chunk } }
187
+ end
188
+ end
189
+
190
+ ##
191
+ # Run an HTTP request, translating the `http` gem's transport-level
192
+ # failures into the client's {Error} hierarchy.
193
+ #
194
+ # @yield The block that issues the request and returns its response.
195
+ # @return [HTTP::Response]
196
+ # @raise [TimeoutError] On an open or read timeout.
197
+ # @raise [ConnectionError] On a socket, DNS, or TLS failure.
198
+ #
199
+ def map_request_errors
200
+ yield
201
+ rescue ::HTTP::TimeoutError => e
202
+ raise TimeoutError, e.message
203
+ rescue ::HTTP::ConnectionError => e
204
+ raise ConnectionError, e.message
205
+ end
206
+
207
+ ##
208
+ # The persistent `http` session for this transport, built once and reused
209
+ # across requests so its keep-alive connection is shared. The session
210
+ # carries the default headers (auth, `Accept`) and the configured
211
+ # timeouts, and reuses an idle connection for up to the configured
212
+ # keep-alive timeout before reopening it; per-request headers are merged
213
+ # over the defaults at the call site via the request's `headers:` option.
214
+ # Scoped to (and pinned to the origin of) this transport instance; not
215
+ # thread-safe.
216
+ #
217
+ # @return [HTTP::Session] The persistent, auth- and timeout-configured
218
+ # session.
219
+ #
220
+ def session
221
+ @session ||=
222
+ begin
223
+ result = ::HTTP.persistent(@config.base_url, timeout: @config.keep_alive_timeout)
224
+ .headers(default_headers)
225
+ # Pass only the timeouts that are set: the `http` gem rejects a nil
226
+ # value for any operation rather than treating it as "no limit".
227
+ timeouts = {read: @config.read_timeout, connect: @config.open_timeout,
228
+ write: @config.write_timeout}.compact
229
+ result = result.timeout(timeouts) unless timeouts.empty?
230
+ result
231
+ end
232
+ end
233
+
234
+ ##
235
+ # Normalize response headers to a plain Hash keyed by downcased name,
236
+ # keeping the layers above {Transport} free of the `http` gem's header
237
+ # type. Repeated headers collapse to their first value.
238
+ #
239
+ # @param headers [#to_h] The response headers.
240
+ # @return [Hash{String=>String}]
241
+ #
242
+ def normalize_headers(headers)
243
+ headers.to_h.each_with_object({}) do |(name, value), result|
244
+ result[name.to_s.downcase] = value.is_a?(::Array) ? value.first : value
245
+ end
246
+ end
247
+
248
+ ##
249
+ # @return [Hash] The headers sent on every request.
250
+ #
251
+ def default_headers
252
+ headers = {"Accept" => "application/json"}
253
+ headers["Authorization"] = "Bearer #{@config.api_key}" if @config.api_key
254
+ headers
255
+ end
256
+
257
+ ##
258
+ # @param path [String] A request path.
259
+ # @return [String] The fully-qualified request URL.
260
+ #
261
+ def url_for(path)
262
+ "#{@config.base_url.chomp('/')}#{path}"
263
+ end
264
+
265
+ ##
266
+ # Parse a successful response or raise on a non-2xx status.
267
+ #
268
+ # @param response [HTTP::Response]
269
+ # @return [Hash] The parsed JSON body.
270
+ #
271
+ def handle(response)
272
+ body = response.body.to_s
273
+ unless response.status.success?
274
+ raise APIError.from_response(status: response.code, body: body,
275
+ headers: normalize_headers(response.headers))
276
+ end
277
+ body.empty? ? {} : Util.parse_json(body)
278
+ end
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require "hermes_agent/client/errors"
6
+
7
+ module HermesAgent
8
+ class Client
9
+ ##
10
+ # Internal helpers shared across the client's layers. Not part of the
11
+ # public API.
12
+ #
13
+ # @private
14
+ module Util
15
+ ##
16
+ # The downcased response-header name carrying the session id.
17
+ #
18
+ SESSION_ID_HEADER = "x-hermes-session-id"
19
+
20
+ ##
21
+ # The downcased response-header name carrying the session key.
22
+ #
23
+ SESSION_KEY_HEADER = "x-hermes-session-key"
24
+
25
+ ##
26
+ # Extract the session-continuity values from a normalized (downcased-key)
27
+ # response-header hash into the keyword form the session-bearing entities
28
+ # ({Entities::ChatCompletion}, {Entities::Response}) accept. Either value
29
+ # is `nil` when the corresponding header is absent.
30
+ #
31
+ # @param headers [Hash{String=>String}] Response headers, keyed by
32
+ # downcased name (as produced by {Transport}).
33
+ # @return [Hash{Symbol=>(String, nil)}] `{session_id:, session_key:}`.
34
+ #
35
+ def self.session_headers(headers)
36
+ {session_id: headers[SESSION_ID_HEADER], session_key: headers[SESSION_KEY_HEADER]}
37
+ end
38
+
39
+ ##
40
+ # Parse JSON from a payload the client expected to be JSON, mapping a
41
+ # parse failure to {MalformedResponseError} so a raw `JSON::ParserError`
42
+ # never leaks out. Shared by the non-streaming ({Transport#handle}) and
43
+ # streaming ({Stream#dispatch}) paths so the two behave identically.
44
+ #
45
+ # @param text [String] The raw text to parse.
46
+ # @return [Object] The parsed JSON value.
47
+ # @raise [MalformedResponseError] If the text is not valid JSON.
48
+ #
49
+ def self.parse_json(text)
50
+ ::JSON.parse(text)
51
+ rescue ::JSON::ParserError
52
+ raise MalformedResponseError.new("Invalid JSON in response body", body: text)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HermesAgent
4
+ class Client
5
+ ##
6
+ # Version of the hermes-client gem
7
+ # @return [String]
8
+ #
9
+ VERSION = "0.1.0"
10
+ end
11
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hermes_agent/client/version"
4
+ require "hermes_agent/client/configuration"
5
+ require "hermes_agent/client/errors"
6
+ require "hermes_agent/client/util"
7
+ require "hermes_agent/client/stream"
8
+ require "hermes_agent/client/transport"
9
+ require "hermes_agent/client/conversation"
10
+ require "hermes_agent/client/resources/capabilities"
11
+ require "hermes_agent/client/resources/chat"
12
+ require "hermes_agent/client/resources/health"
13
+ require "hermes_agent/client/resources/jobs"
14
+ require "hermes_agent/client/resources/models"
15
+ require "hermes_agent/client/resources/responses"
16
+ require "hermes_agent/client/resources/runs"
17
+
18
+ ##
19
+ # Classes related to the Hermes agent
20
+ #
21
+ module HermesAgent
22
+ ##
23
+ # A client for the Hermes API server.
24
+ #
25
+ # Construct a client with the server location and credentials, then reach
26
+ # endpoints through resource accessors such as {#health}.
27
+ #
28
+ # client = HermesAgent::Client.new(base_url: "http://127.0.0.1:8642")
29
+ # client.health.check.status # => "ok"
30
+ #
31
+ # Note this class is not thread-safe. When using in a multithreaded
32
+ # application, you should create a separate client object per thread.
33
+ #
34
+ class Client
35
+ # Sentinel distinguishing an omitted `api_key:` (defer to {Configuration}'s
36
+ # `HERMES_API_KEY` default) from an explicit `nil` (send no auth header).
37
+ UNSET = ::Object.new
38
+ private_constant :UNSET
39
+
40
+ ##
41
+ # Create a client.
42
+ #
43
+ # Settings may be supplied as keyword arguments, by yielding the
44
+ # {Configuration} to a block, or both (the block runs after the keyword
45
+ # arguments are applied).
46
+ #
47
+ # @param base_url [String] The server root URL. See {Configuration}.
48
+ # @param api_key [String, nil] The bearer token. When omitted, defaults to
49
+ # the `HERMES_API_KEY` environment variable (see {Configuration}); pass
50
+ # `nil` explicitly to send no `Authorization` header regardless.
51
+ # @param read_timeout [Numeric, nil] The read timeout in seconds.
52
+ # @param open_timeout [Numeric, nil] The connection-open timeout in seconds.
53
+ # @param write_timeout [Numeric, nil] The request-write timeout in seconds.
54
+ # @param keep_alive_timeout [Numeric] How long an idle persistent
55
+ # connection may be reused before being reopened. See {Configuration}.
56
+ # @yieldparam config [Configuration] The configuration, for customization.
57
+ #
58
+ def initialize(base_url: Configuration::DEFAULT_BASE_URL,
59
+ api_key: UNSET,
60
+ read_timeout: nil,
61
+ open_timeout: nil,
62
+ write_timeout: nil,
63
+ keep_alive_timeout: Configuration::DEFAULT_KEEP_ALIVE_TIMEOUT)
64
+ @config = Configuration.new(base_url: base_url, read_timeout: read_timeout,
65
+ open_timeout: open_timeout, write_timeout: write_timeout,
66
+ keep_alive_timeout: keep_alive_timeout)
67
+ @config.api_key = api_key unless api_key.equal?(UNSET)
68
+ yield @config if block_given?
69
+ @transport = Transport.new(@config)
70
+ end
71
+
72
+ ##
73
+ # The configuration this client was built with.
74
+ # @return [Configuration]
75
+ #
76
+ attr_reader :config
77
+
78
+ ##
79
+ # The capabilities resource (the server's advertised endpoints and
80
+ # feature matrix).
81
+ # @return [Resources::Capabilities]
82
+ #
83
+ def capabilities
84
+ @capabilities ||= Resources::Capabilities.new(@transport)
85
+ end
86
+
87
+ ##
88
+ # The chat resource (OpenAI-compatible chat completions).
89
+ # @return [Resources::Chat]
90
+ #
91
+ def chat
92
+ @chat ||= Resources::Chat.new(@transport)
93
+ end
94
+
95
+ ##
96
+ # The health resource (server health checks).
97
+ # @return [Resources::Health]
98
+ #
99
+ def health
100
+ @health ||= Resources::Health.new(@transport)
101
+ end
102
+
103
+ ##
104
+ # The jobs resource (the Jobs API, for scheduled background work).
105
+ # @return [Resources::Jobs]
106
+ #
107
+ def jobs
108
+ @jobs ||= Resources::Jobs.new(@transport)
109
+ end
110
+
111
+ ##
112
+ # The models resource (discovery of advertised models).
113
+ # @return [Resources::Models]
114
+ #
115
+ def models
116
+ @models ||= Resources::Models.new(@transport)
117
+ end
118
+
119
+ ##
120
+ # The responses resource (the Responses API, with server-side
121
+ # conversation state).
122
+ # @return [Resources::Responses]
123
+ #
124
+ def responses
125
+ @responses ||= Resources::Responses.new(@transport)
126
+ end
127
+
128
+ ##
129
+ # The runs resource (the Runs API, for long-running server-side agent
130
+ # runs).
131
+ # @return [Resources::Runs]
132
+ #
133
+ def runs
134
+ @runs ||= Resources::Runs.new(@transport)
135
+ end
136
+ end
137
+ end
metadata CHANGED
@@ -1,26 +1,87 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hermes-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
- - dazuma@gmail.com
7
+ - Daniel Azuma
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
12
- description: This is a placeholder gem, which was generated on 2026-05-22 to reserve
13
- the gem hermes-client. The actual gem is planned for release in the near future.
14
- If this is a problem, or if the actual gem has not been released in a timely manner,
15
- you can contact the owner at dazuma@gmail.com.
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: http
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '6.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '6.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: http-cookie
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.1'
40
+ description: This is a basic client library for the API Server that ships with the
41
+ Hermes AI Agent.
42
+ email:
43
+ - dazuma@gmail.com
16
44
  executables: []
17
45
  extensions: []
18
46
  extra_rdoc_files: []
19
47
  files:
48
+ - ".yardopts"
49
+ - CHANGELOG.md
50
+ - LICENSE.md
20
51
  - README.md
21
52
  - lib/hermes-client.rb
22
- licenses: []
23
- metadata: {}
53
+ - lib/hermes_agent/client.rb
54
+ - lib/hermes_agent/client/configuration.rb
55
+ - lib/hermes_agent/client/conversation.rb
56
+ - lib/hermes_agent/client/entities/capabilities.rb
57
+ - lib/hermes_agent/client/entities/chat_completion.rb
58
+ - lib/hermes_agent/client/entities/health.rb
59
+ - lib/hermes_agent/client/entities/job.rb
60
+ - lib/hermes_agent/client/entities/model.rb
61
+ - lib/hermes_agent/client/entities/response.rb
62
+ - lib/hermes_agent/client/entities/run.rb
63
+ - lib/hermes_agent/client/entities/session_headers.rb
64
+ - lib/hermes_agent/client/entity.rb
65
+ - lib/hermes_agent/client/errors.rb
66
+ - lib/hermes_agent/client/resources/capabilities.rb
67
+ - lib/hermes_agent/client/resources/chat.rb
68
+ - lib/hermes_agent/client/resources/health.rb
69
+ - lib/hermes_agent/client/resources/jobs.rb
70
+ - lib/hermes_agent/client/resources/models.rb
71
+ - lib/hermes_agent/client/resources/responses.rb
72
+ - lib/hermes_agent/client/resources/runs.rb
73
+ - lib/hermes_agent/client/stream.rb
74
+ - lib/hermes_agent/client/transport.rb
75
+ - lib/hermes_agent/client/util.rb
76
+ - lib/hermes_agent/client/version.rb
77
+ homepage: https://github.com/dazuma/hermes-client
78
+ licenses:
79
+ - MIT
80
+ metadata:
81
+ bug_tracker_uri: https://github.com/dazuma/hermes-client/issues
82
+ changelog_uri: https://rubydoc.info/gems/hermes-client/0.1.0/file/CHANGELOG.md
83
+ documentation_uri: https://rubydoc.info/gems/hermes-client/0.1.0
84
+ homepage_uri: https://github.com/dazuma/hermes-client
24
85
  rdoc_options: []
25
86
  require_paths:
26
87
  - lib
@@ -28,7 +89,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
28
89
  requirements:
29
90
  - - ">="
30
91
  - !ruby/object:Gem::Version
31
- version: '0'
92
+ version: '3.4'
32
93
  required_rubygems_version: !ruby/object:Gem::Requirement
33
94
  requirements:
34
95
  - - ">="
@@ -37,5 +98,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
37
98
  requirements: []
38
99
  rubygems_version: 4.0.10
39
100
  specification_version: 4
40
- summary: Placeholder gem
101
+ summary: A client for the Hermes Agent API Server.
41
102
  test_files: []