calmlens 0.21.0 → 0.23.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.
@@ -8,8 +8,7 @@ require_relative 'test_image_grid'
8
8
  module CalmLens
9
9
  # Signed test inputs: text codes, test images and media phrases that a project's moderation
10
10
  # recognises as a declared scenario when they are signed with its secret test input key (shown in
11
- # the portal's project settings to those who can read them) and the project has "Recognise test
12
- # inputs" turned on, so CalmLens returns that scenario instead of classifying them. The formats
11
+ # the portal's project settings to those who can read them), so CalmLens returns that scenario instead of classifying them. The formats
13
12
  # match the JavaScript SDK's `calmlens/test-inputs` byte for byte.
14
13
  #
15
14
  # A scenario is a Hash with the API's keys, as Strings or Symbols (snake_case keys are converted
@@ -57,7 +56,7 @@ module CalmLens
57
56
  person = { 'box' => [2500, 1500, 5000, 8500] }
58
57
  face = ->(age = nil) { { 'box' => [3500, 2000, 3000, 3500], **(age ? { 'age' => age } : {}) } }
59
58
  # Ready-made scenarios (deeply frozen): a clean input, a limited one, adult images and text,
60
- # regional delivery, content restricted in Germany, mixed instances in one category, and test
59
+ # regional delivery, delivery blocked in Germany, mixed instances in one category, and test
61
60
  # images with one person and face, nobody, an adult's face, a minor's face and a group of three.
62
61
  PRESETS = deep_freeze.call(
63
62
  clean: { 'instances' => [] },
@@ -67,7 +66,7 @@ module CalmLens
67
66
  regional_delivery: { 'instances' => [], 'regionalActions' => [{ 'region' => 'DE', 'action' => 'block' },
68
67
  { 'region' => 'GB', 'action' => 'age-restrict' },
69
68
  { 'region' => 'FR', 'action' => 'limit' }] },
70
- germany: one.call('hate', 'hate-symbol', 'hate-nazi-symbols-public-display'),
69
+ germany: { 'instances' => [], 'regionalActions' => [{ 'region' => 'DE', 'action' => 'block' }] },
71
70
  mixed: { 'instances' => [{ 'category' => 'harassment', 'qualifiers' => %w[harassment-abuse harassment-documentary] },
72
71
  { 'category' => 'harassment', 'qualifiers' => ['harassment-threat'] }] },
73
72
  one_face: { 'instances' => [], 'detections' => { 'people' => [person], 'faces' => [face.call] } },
@@ -98,7 +97,8 @@ module CalmLens
98
97
  preset = case signal
99
98
  when 'harassment.insult' then PRESETS[:limited]
100
99
  when 'sexual.adult' then PRESETS[kind == 'image' ? :adult_image : :adult_text]
101
- when 'hate.regional-symbol' then PRESETS[:germany]
100
+ when 'hate.regional-symbol'
101
+ { 'instances' => [{ 'category' => 'hate', 'qualifiers' => %w[hate-symbol hate-nazi-symbols-public-display] }] }
102
102
  when 'sexual.involving-minors' then { 'instances' => [{ 'category' => 'sexual', 'qualifiers' => ['sexual-minors'] }] }
103
103
  when 'violence.graphic'
104
104
  { 'instances' => [{ 'category' => 'violence', 'qualifiers' => %w[violence-injury violence-graphic] }] }
@@ -27,8 +27,10 @@ module CalmLens
27
27
  # grant (the default key has no grants), or the URL is signed: {CDN#sign_asset_url} returns
28
28
  # `{cdn_base_url}/assets/{project_id}/sig_…`, a readable grant that only the mock's CDN accepts,
29
29
  # which serves `original` only when signed with `original: true`. {#fetch_cdn} loads such a URL
30
- # without the API key, as a browser would. {#decide} applies a reviewer's decision, and
31
- # {Assets#wait_for_decision} takes no real time: a pending asset times out at once.
30
+ # without the API key, as a browser would; without the key or a grant, a private asset answers
31
+ # as an unknown one does, `asset/not-found` (404) whatever its state, and only its placeholder is
32
+ # public. {#decide} applies a reviewer's decision, and {Assets#wait_for_decision} takes no real
33
+ # time: a pending asset times out at once.
32
34
  # Filters join comparisons with `;` (and), which binds tighter than `,` (or), and group them with
33
35
  # `( … )`; list cursors resume after the last item returned. {Assets#search} is the API's keyword
34
36
  # search: every query word (case-insensitive, up to 8) must appear in a searchable field, and
@@ -47,12 +49,26 @@ module CalmLens
47
49
  #
48
50
  # `path` is relative to `base_url` for API calls (`/projects/<id>/assets`, without `/v1`) and to
49
51
  # `cdn_base_url` for CDN requests, with each segment decoded. `query` holds the decoded query
50
- # string. `body` is the bytes sent: JSON text, or the uploaded content (streams are read), or
51
- # nil. `status` is the HTTP status the mock answered with, or nil when the request failed
52
- # without a response.
52
+ # string; an upload's holds only transport values (`wait`, `contentType`, sizes and part
53
+ # numbers). `body` is the bytes sent (streams are read), or nil: JSON text, including a text
54
+ # upload (`{name, ..., content: {plainText, type}}`), an upload form, or a later part's bytes.
55
+ # `status` is the HTTP status the mock answered with, or nil when the request failed without a
56
+ # response.
53
57
  Request = Struct.new(:method, :url, :path, :query, :headers, :body, :status, keyword_init: true) do
54
58
  # @return [Object] the body parsed as JSON.
55
59
  def json = body && JSON.parse(body)
60
+
61
+ # An upload form's asset fields and file: `{'options' => Hash, 'file' => String}`, the file as
62
+ # UTF-8 text for a `text/*` content type and binary otherwise; nil for any other body.
63
+ # @return [Hash, nil]
64
+ def form
65
+ type = headers.find { |name, _| name.to_s.casecmp?('Content-Type') }&.last
66
+ options, file = Testing::FakeAPI.upload_form(body, type)
67
+ return nil unless options
68
+
69
+ text = query['contentType'].to_s.downcase.start_with?('text/')
70
+ { 'options' => JSON.parse(options), 'file' => text ? file.dup.force_encoding(Encoding::UTF_8) : file }
71
+ end
56
72
  end
57
73
 
58
74
  # @return [Testing::State] the stored assets, folders, content and events.
@@ -64,12 +80,15 @@ module CalmLens
64
80
  REQUEST_FAILURES = %w[transport-error timeout].freeze
65
81
  # The methods {#fail_next_request} can target, by resource. Iterators are their list calls.
66
82
  OPERATIONS = { assets: %i[search list retrieve list_children list_timeline update create classify upload_part move
67
- retry explain wait_for_decision remove_content delete batch],
83
+ retry explain decide wait_for_decision remove_content delete batch],
68
84
  folders: %i[list retrieve create update delete move],
69
85
  hash_lists: %i[list retrieve create update delete list_entries add_entry remove_entry],
70
86
  policies: %i[list retrieve retrieve_assigned create update reset upgrade set_automatic_updates delete
71
87
  assign],
72
88
  lenses: %i[list create retrieve update delete],
89
+ upload_presets: %i[list ensure delete],
90
+ project_settings: %i[get update],
91
+ metadata_fields: %i[list ensure delete],
73
92
  text: %i[classify], events: %i[list],
74
93
  cdn: %i[serve sign_asset_url get_asset_info] }.freeze
75
94
  private_constant :OPERATIONS
@@ -183,8 +202,9 @@ module CalmLens
183
202
 
184
203
  # Applies a reviewer's decision to a stored asset, as the portal or API would: the moderation
185
204
  # status changes (`purged` also removes the content), the review joins `manualReviews` with
186
- # `decidedBy` the mock API key (`{ 'type' => 'api-key', 'id' => nil }`) and `applied: true`, and an
187
- # `asset.<decision>` event whose `actor` is a `user` joins the events feed.
205
+ # `decidedBy` the mock API key (`{ 'type' => 'api-key', 'id' => nil }`) and `applied: true`, an
206
+ # `asset.<decision>` event whose `actor` is a `user` joins the events feed, and a waiting
207
+ # `pendingReview` is settled ({Assets#decide} leaves it waiting, as with the API).
188
208
  # @param asset_id [String]
189
209
  # @param decision [String] `allowed`, `limited`, `age-restricted`, `blocked` or `purged`
190
210
  # @return [Hash] the asset
@@ -254,7 +274,8 @@ module CalmLens
254
274
  # Fetches a URL of the mock's CDN as a browser would, with a GET and without the API key: a signed
255
275
  # asset URL from {CDN#sign_asset_url} (with a resource appended), or a public one from
256
276
  # {CDN#get_asset_url}. The request is recorded like any other. Error statuses are returned, not
257
- # raised.
277
+ # raised. A private asset without a grant answers `asset/not-found` (404) whatever its state,
278
+ # except for its public placeholder.
258
279
  # @param url [String]
259
280
  # @return [Response] with its body read
260
281
  def fetch_cdn(url)
@@ -46,20 +46,6 @@ module CalmLens
46
46
  "blur": {
47
47
  "type": "number"
48
48
  },
49
- "redact": {
50
- "type": "object",
51
- "properties": {
52
- "text": {
53
- "type": "boolean"
54
- },
55
- "personalInformation": {
56
- "type": "boolean"
57
- },
58
- "faces": {
59
- "type": "boolean"
60
- }
61
- }
62
- },
63
49
  "border": {
64
50
  "type": "object",
65
51
  "properties": {
@@ -19,7 +19,10 @@ module CalmLens
19
19
  # @!attribute open_timeout [Numeric] seconds allowed to open a connection.
20
20
  # @!attribute timeout [Numeric] seconds allowed for each read or write; not a whole-transfer deadline.
21
21
  Request = Struct.new(:method, :url, :headers, :body, :open_timeout, :timeout, keyword_init: true) do
22
+ # Struct's own `to_s` and `pretty_print` would show the headers, which hold the API key.
22
23
  def inspect = "#<#{self.class.name} #{method} #{url}>"
24
+ def to_s = inspect
25
+ def pretty_print(printer) = printer.text(inspect)
23
26
  end
24
27
 
25
28
  # Exceptions that mean the connection failed before a complete response arrived.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+
3
5
  module CalmLens
4
6
  # An upload payload: a binary String, or an IO streamed with an exact byte count (or, when its
5
7
  # length is unknown, until it ends, sent with chunked transfer encoding).
@@ -98,6 +100,20 @@ module CalmLens
98
100
  UploadBody.new(@source.slice(offset, length))
99
101
  end
100
102
 
103
+ # This body as the `file` part of an upload form, after an `options` part holding
104
+ # `options_json`: `multipart/form-data` with a random boundary. A stream stays a stream, read
105
+ # as the form is sent, and the form can be sent again whenever this body can.
106
+ # @return [Array(UploadBody, String)] the form and its Content-Type.
107
+ def to_form(options_json, type)
108
+ boundary = "calmlens-#{SecureRandom.uuid}"
109
+ head = "--#{boundary}\r\nContent-Disposition: form-data; name=\"options\"\r\nContent-Type: application/json\r\n\r\n".b +
110
+ options_json.b + "\r\n--#{boundary}\r\nContent-Disposition: form-data; name=\"file\"\r\n".b +
111
+ "Content-Type: #{type.delete("\r\n")}\r\n\r\n".b
112
+ tail = "\r\n--#{boundary}--\r\n".b
113
+ source = @source.is_a?(String) ? head + @source + tail : FormReader.new(head, @source, tail)
114
+ [UploadBody.new(source), "multipart/form-data; boundary=#{boundary}"]
115
+ end
116
+
101
117
  # The bytes, read fully. Used only by the offline mock.
102
118
  def read_all
103
119
  return @source if @source.is_a?(String)
@@ -111,9 +127,12 @@ module CalmLens
111
127
  class IOReader
112
128
  attr_reader :size
113
129
 
114
- def initialize(io, size)
130
+ # @param bounded [Boolean] the stream goes on after `size` bytes (a slice of a larger file), so
131
+ # reading stops there without checking for more.
132
+ def initialize(io, size, bounded: false)
115
133
  @io = io
116
134
  @size = size
135
+ @bounded = bounded
117
136
  @remaining = size
118
137
  @start = (io.pos if io.respond_to?(:pos) && io.respond_to?(:seek))
119
138
  rescue IOError, SystemCallError
@@ -125,7 +144,7 @@ module CalmLens
125
144
  # A reader of `length` bytes from `offset` of this rewindable stream, positioned there.
126
145
  def slice(offset, length)
127
146
  @io.seek(@start + offset)
128
- IOReader.new(@io, length)
147
+ IOReader.new(@io, length, bounded: true)
129
148
  end
130
149
 
131
150
  def rewind
@@ -138,6 +157,8 @@ module CalmLens
138
157
  return read_unsized(length, buffer) if @size.nil?
139
158
 
140
159
  if @remaining.zero?
160
+ return length.nil? ? ''.b : buffer&.clear && nil if @bounded
161
+
141
162
  extra = @io.read(1)
142
163
  unless extra.nil? || extra.empty?
143
164
  raise Internal.invalid('The stream holds more bytes than sizeInBytes',
@@ -169,6 +190,68 @@ module CalmLens
169
190
  read(length, buffer) || raise(EOFError)
170
191
  end
171
192
  end
193
+
194
+ # An upload form around a streamed file: the form's head, the file's bytes as an {IOReader}
195
+ # reads them, then its closing boundary.
196
+ class FormReader
197
+ def initialize(head, file, tail)
198
+ @head = head
199
+ @file = file
200
+ @tail = tail
201
+ @size = file.size && (head.bytesize + file.size + tail.bytesize)
202
+ rewind_pieces
203
+ end
204
+
205
+ attr_reader :size
206
+
207
+ def rewindable? = @file.rewindable?
208
+
209
+ def rewind
210
+ @file.rewind
211
+ rewind_pieces
212
+ end
213
+
214
+ # Reads like IO#read: everything left without a length, else up to `length` bytes, nil at the end.
215
+ def read(length = nil, buffer = nil)
216
+ return (buffer ? buffer.replace(''.b) : ''.b) if length&.zero?
217
+
218
+ output = ''.b
219
+ until @pieces.empty? || (length && output.bytesize >= length)
220
+ chunk = read_piece(length && (length - output.bytesize))
221
+ chunk.nil? || chunk.empty? ? next_piece : output << chunk
222
+ end
223
+ return buffer&.clear && nil if output.empty? && length
224
+
225
+ buffer ? buffer.replace(output) : output
226
+ end
227
+
228
+ def readpartial(length, buffer = nil)
229
+ read(length, buffer) || raise(EOFError)
230
+ end
231
+
232
+ private
233
+
234
+ def rewind_pieces
235
+ @pieces = %i[head file tail]
236
+ @offset = 0
237
+ end
238
+
239
+ def next_piece
240
+ @pieces.shift
241
+ @offset = 0
242
+ end
243
+
244
+ # Up to `length` bytes (all, without one) of the current piece; empty or nil once it is used up.
245
+ def read_piece(length)
246
+ piece = @pieces.first
247
+ return @file.read(length) if piece == :file
248
+
249
+ text = piece == :head ? @head : @tail
250
+ chunk = text.byteslice(@offset, length || text.bytesize).to_s
251
+ @offset += chunk.bytesize
252
+ chunk
253
+ end
254
+ end
172
255
  end
173
256
  private_constant :UploadBody
174
257
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CalmLens
4
4
  # The gem version. The gemspec and the User-Agent header read it from here.
5
- VERSION = '0.21.0'
5
+ VERSION = '0.23.0'
6
6
  end
data/lib/calmlens.rb CHANGED
@@ -29,5 +29,6 @@ require_relative 'calmlens/upload_tokens'
29
29
  require_relative 'calmlens/events'
30
30
  require_relative 'calmlens/cdn'
31
31
  require_relative 'calmlens/lenses'
32
+ require_relative 'calmlens/project_configuration'
32
33
  require_relative 'calmlens/server_client'
33
34
  require_relative 'calmlens/webhooks'
data/sig/calmlens.rbs CHANGED
@@ -31,6 +31,7 @@ module CalmLens
31
31
  ?headers: Hash[String, String], ?request_id: String?) -> void
32
32
  # Whole seconds from the `Retry-After` header.
33
33
  def retry_after: () -> Integer?
34
+ def local?: () -> bool
34
35
  def not_found?: () -> bool
35
36
  def retryable?: () -> bool
36
37
  def self.build: (String code, ?String? message, ?status: Integer?, ?details: Hash[String, json_value]?,
@@ -122,6 +123,9 @@ module CalmLens
122
123
  attr_reader events: Events
123
124
  attr_reader cdn: CDN
124
125
  attr_reader lenses: Lenses
126
+ attr_reader upload_presets: UploadPresets
127
+ attr_reader project_settings: ProjectSettings
128
+ attr_reader metadata_fields: MetadataFields
125
129
  attr_reader project_id: String
126
130
  attr_reader base_url: String
127
131
  attr_reader cdn_base_url: String
@@ -237,6 +241,25 @@ module CalmLens
237
241
  def delete: (lens_id_options options) -> nil
238
242
  end
239
243
 
244
+ class UploadPresets < Resource
245
+ def list: (?list_upload_presets_options options) -> upload_preset_page
246
+ def each: (?list_upload_presets_options options) { (upload_preset) -> void } -> nil
247
+ | (?list_upload_presets_options options) -> Enumerator[upload_preset, nil]
248
+ def ensure: (ensure_upload_presets_options options) -> ensure_upload_presets_response
249
+ def delete: (upload_preset_id_options options) -> nil
250
+ end
251
+
252
+ class ProjectSettings < Resource
253
+ def get: (?request_only_options options) -> project_settings_resource
254
+ def update: (update_project_settings_options options) -> project_settings_resource
255
+ end
256
+
257
+ class MetadataFields < Resource
258
+ def list: (?request_only_options options) -> metadata_fields_resource
259
+ def ensure: (ensure_metadata_fields_options options) -> metadata_fields_resource
260
+ def delete: (delete_metadata_field_options options) -> nil
261
+ end
262
+
240
263
  class UploadTokens < Resource
241
264
  TOKEN_PREFIX: String
242
265
  SIGNING_CONTEXT: String
@@ -379,6 +402,8 @@ module CalmLens
379
402
  # The HTTP status the mock answered with; nil when the request failed without a response.
380
403
  attr_accessor status: Integer?
381
404
  def json: () -> json_value
405
+ # An upload form's `options` (parsed) and `file` (its bytes); nil for any other body.
406
+ def form: () -> { 'options' => Hash[String, json_value], 'file' => String }?
382
407
  end
383
408
 
384
409
  interface _Clock
@@ -445,8 +470,11 @@ module CalmLens
445
470
  # Flags `category` with a score from 0 to 1 and optional qualifier scores.
446
471
  def flag: (String | Symbol category, Numeric score, ?Hash[String | Symbol, Numeric] qualifiers) -> self
447
472
  def not_evaluated: (String | Symbol category) -> self
473
+ # Leaves the decision waiting for human review: `sampled`, or `uncertain` with the categories that made it so.
474
+ def pending_review: ("sampled" | "uncertain" | :sampled | :uncertain reason, ?Array[String | Symbol] uncertain_categories) -> self
448
475
  # The `categories`, `score` and `policy` a decided asset gets; nil for `pending` and `failed`.
449
- def report: () -> Hash[String, json_value]?
476
+ # `requested_at` is when a pending review joined the queue, in epoch milliseconds.
477
+ def report: (?Integer requested_at) -> Hash[String, json_value]?
450
478
  end
451
479
 
452
480
  # What MockServerClient's `moderate:` callback receives.
data/sig/error_codes.rbs CHANGED
@@ -37,12 +37,14 @@ module CalmLens
37
37
  PERMISSION_DENIED: String
38
38
  UPLOAD_TOO_LARGE: String
39
39
  DOCUMENT_TOO_MANY_ITEMS: String
40
+ MEDIA_DURATION_LIMIT: String
40
41
  INVALID_IMAGE: String
41
42
  IMAGE_TOO_LARGE: String
42
43
  FREE_UPLOADS_EXHAUSTED: String
43
44
  UPLOADS_PAUSED: String
44
45
  UNSUPPORTED_UPLOAD_TYPE: String
45
46
  INVALID_UPLOAD_PAYLOAD: String
47
+ UPLOAD_OPTIONS_IN_QUERY: String
46
48
  USE_UPLOAD_PART: String
47
49
  UPLOAD_NOT_OPEN: String
48
50
  INVALID_UPLOAD_PART_NUMBER: String
@@ -99,6 +101,14 @@ module CalmLens
99
101
  UPLOAD_PRESET_DAILY_LIMIT_REACHED: String
100
102
  UPLOAD_PRESET_ORIGIN_NOT_ALLOWED: String
101
103
  UPLOAD_PRESET_FIELD_NOT_ALLOWED: String
104
+ UPLOAD_PRESET_REVISION_CONFLICT: String
105
+ UPLOAD_PRESET_KEY_TAKEN: String
106
+ UPLOAD_PRESET_DAILY_LIMIT_REQUIRED: String
107
+ UPLOAD_PRESET_INVALID_FOLDER: String
108
+ UPLOAD_PRESET_INVALID_POLICY: String
109
+ PROJECT_SETTINGS_REVISION_CONFLICT: String
110
+ SEARCH_ESTIMATE_CHANGED: String
111
+ METADATA_FIELD_NOT_FOUND: String
102
112
  UPLOAD_TOKEN_INVALID: String
103
113
  UPLOAD_TOKEN_EXPIRED: String
104
114
  UPLOAD_TOKEN_USED: String
data/sig/qualifiers.rbs CHANGED
@@ -57,6 +57,7 @@ module CalmLens
57
57
  ?"sexual-educational" => qualifier_result,
58
58
  ?"sexual-discussion" => qualifier_result,
59
59
  ?"sexual-fictional" => qualifier_result,
60
+ ?"sexual-artistic" => qualifier_result,
60
61
  ?"sexual-support-prevention" => qualifier_result,
61
62
  ?"violence-attack" => qualifier_result,
62
63
  ?"violence-threat" => qualifier_result,