inferno_core 1.4.2 → 1.4.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f1318358aa5a9dfd3f856740eb3b02ca30c5a960893595e44631fea414906936
4
- data.tar.gz: ddf3a22b024d5745efeead73eacc55a23d7c0b9841e57177df877ed46457dc3a
3
+ metadata.gz: 044cfcbc6d3920cfba55d0d63fbf0fb3a1440f4f42a2e45d763beb78f280306a
4
+ data.tar.gz: 243c006acc71fddd3eb165a2c400a1eab4e515f8c3f4915b27cd60fc8e41e734
5
5
  SHA512:
6
- metadata.gz: 5ddfa620841f6acb416190f683235fefeb089e623f238578ac7cd0022bc01ece6aee0644bae93b6d9f4ee72aff33fa4cefeda871a9a160502cd26dde0b193260
7
- data.tar.gz: 718dcda3cd89f1f9d673eaa7596ec5dc8b4dd405b9434c6a55eb2caf381d2cd1516614098ac565d1104c410991ec6496b5e11ecfaf66df77b4d49df1ae5c569f
6
+ metadata.gz: a1c934c0c6bb863a66e34b2830807ee274f443323d7c74c10023bf078329abf2bc70d9125f481b388c3e9ac61a647c81a8f6ee6f7c156078a9dc4fc31adcb671
7
+ data.tar.gz: 6a54d11609e88fadd4c4750966e66a1df705ab1a1c79b8c8c0b99f5d3a19467496c46db740dfb2937610c9c78e8a7bf14db7fe8112c7c8399bb43a16bdcd6937
@@ -138,7 +138,7 @@ module Inferno
138
138
 
139
139
  ExecutionStatus = Struct.new(
140
140
  :done, :failed, :timed_out, :cancel_pending, :current_session, :current_timeout, :last_log_time,
141
- :cross_session_status, :last_step_signatures
141
+ :poll_start_time, :cross_session_status, :last_step_signatures
142
142
  )
143
143
 
144
144
  attr_accessor :yaml_file, :options, :execution_status
@@ -155,6 +155,7 @@ module Inferno
155
155
  cancel_pending: false,
156
156
  current_session: sessions.first,
157
157
  current_timeout: options[:default_poll_timeout],
158
+ poll_start_time: nil,
158
159
  cross_session_status: {},
159
160
  last_step_signatures: {}
160
161
  )
@@ -416,6 +417,7 @@ module Inferno
416
417
  warn ''
417
418
  warn "Polling session: #{session.key} (#{session.session_id}) timeout=#{timeout}s"
418
419
  deadline = Time.now + timeout
420
+ execution_status.poll_start_time = Time.now
419
421
  execution_status.last_log_time = Time.now - LOG_INTERVAL_SECONDS
420
422
 
421
423
  loop do
@@ -518,12 +520,14 @@ module Inferno
518
520
  last_completed = last_completed_from_status(status)
519
521
  poll_status_last_test =
520
522
  last_completed.present? ? " - last test: #{format_last_completed(last_completed, session_key)}" : ''
521
- warn " [#{session_key}] #{status['status']} (#{test_progress(status)})#{poll_status_last_test}"
523
+ elapsed = (Time.now - execution_status.poll_start_time).round
524
+ warn " [#{session_key}] #{status['status']} (#{test_progress(status, elapsed)})" \
525
+ "#{poll_status_last_test}"
522
526
  execution_status.last_log_time = Time.now
523
527
  end
524
528
 
525
- def test_progress(status)
526
- "#{status['completed_test_count']}/#{status['test_count']} tests"
529
+ def test_progress(status, elapsed)
530
+ "completed #{status['completed_test_count']}/#{status['test_count']} tests in #{elapsed}s"
527
531
  end
528
532
 
529
533
  def fetch_session_status(session_id)
@@ -119,7 +119,7 @@ module Inferno
119
119
  suite_runnables.select { |runnable| runnable.verifies_requirements.include? requirement_id }
120
120
 
121
121
  runnables_for_requirement.map do |runnable|
122
- [requirement_id, runnable.short_id, runnable.id]
122
+ [requirement_id, runnable < Inferno::Entities::TestSuite ? 'suite' : runnable.short_id, runnable.id]
123
123
  end
124
124
  end
125
125
  end
@@ -42,8 +42,13 @@ module Inferno
42
42
  # @param given_element [FHIR::Model, Array<FHIR::Model>]
43
43
  # @param path [String]
44
44
  # @param include_dar [Boolean]
45
+ # @param recursive_segments [Array<String>] names of path segments that are
46
+ # self-referential (eg. `item` in Questionnaire.item.item, via a FHIR
47
+ # `contentReference`). When a segment matches, the search also continues
48
+ # into deeper repeats of that segment (item.item.item...) if the value
49
+ # isn't found at the literal path depth.
45
50
  # @return a single matching value (which can include `false`) or `nil` if not found
46
- def find_a_value_at(given_element, path, include_dar: false, &block)
51
+ def find_a_value_at(given_element, path, include_dar: false, recursive_segments: [], &block)
47
52
  return nil if given_element.nil?
48
53
 
49
54
  elements = Array.wrap(given_element)
@@ -56,8 +61,13 @@ module Inferno
56
61
  remaining_path = path_segments.join('.')
57
62
  elements.each do |element|
58
63
  child = get_next_value(element, segment)
59
- element_found = find_a_value_at(child, remaining_path, include_dar:, &block)
64
+ element_found = find_a_value_at(child, remaining_path, include_dar:, recursive_segments:, &block)
60
65
  return element_found if value_not_empty?(element_found)
66
+
67
+ next unless recursive_segments.include?(segment)
68
+
69
+ nested_found = find_a_value_at(child, path, include_dar:, recursive_segments:, &block)
70
+ return nested_found if value_not_empty?(nested_found)
61
71
  end
62
72
 
63
73
  nil
@@ -26,6 +26,7 @@ module Inferno
26
26
  # allowExampleUrls true
27
27
  # txServer nil
28
28
  # end
29
+ # expansion_parameters 'path/to/expansion_parameters.json'
29
30
  # end
30
31
  module FHIRResourceValidation
31
32
  def self.included(klass)
@@ -118,6 +119,99 @@ module Inferno
118
119
 
119
120
  alias cli_context validation_context
120
121
 
122
+ # Environment variable containing the default expansion parameters.
123
+ # Used by {#expansion_parameters} when no value has been set
124
+ # explicitly. May contain either the raw JSON content of a FHIR
125
+ # Parameters resource (if it starts with `{`) or a path to a file
126
+ # containing one.
127
+ EXPANSION_PARAMETERS_ENV_VAR = 'FHIR_RESOURCE_VALIDATOR_EXPANSION_PARAMETERS'.freeze
128
+
129
+ # Set the expansion parameters to be sent with each validation
130
+ # request. This configures how the validator's terminology engine
131
+ # expands value sets during validation (e.g. designation
132
+ # preferences, forcing the use of the latest terminology versions,
133
+ # etc.). The content is sent inline with every validation request
134
+ # made by this validator, since the validator does not have access
135
+ # to Inferno's filesystem.
136
+ #
137
+ # Accepts either a Hash containing the contents of a FHIR Parameters
138
+ # resource, or a String path to a file (JSON or XML) containing one.
139
+ # The file is read once, the first time it's needed.
140
+ #
141
+ # If never set explicitly, this falls back to the
142
+ # `FHIR_RESOURCE_VALIDATOR_EXPANSION_PARAMETERS` environment
143
+ # variable, if present. This allows a shared set of expansion
144
+ # parameters to be configured once for every test kit that uses a
145
+ # given validator instance, while still letting individual test
146
+ # kits opt out or override it by calling this method themselves.
147
+ # The environment variable's content is treated as raw JSON if it
148
+ # starts with `{`, and otherwise as a file path.
149
+ #
150
+ # @example
151
+ # # Passing a Hash
152
+ # fhir_resource_validator do
153
+ # url 'http://example.com/validator'
154
+ # expansion_parameters({
155
+ # resourceType: 'Parameters',
156
+ # parameter: [{ name: 'excludeNested', valueBoolean: true }]
157
+ # })
158
+ # end
159
+ #
160
+ # @example
161
+ # # Passing a file path
162
+ # fhir_resource_validator do
163
+ # url 'http://example.com/validator'
164
+ # expansion_parameters 'path/to/expansion_parameters.json'
165
+ # end
166
+ #
167
+ # @param value [Hash, String, nil] contents of a Parameters resource
168
+ # as a Hash, or a path to a file (JSON or XML) containing one
169
+ def expansion_parameters(value = nil)
170
+ if value
171
+ @expansion_parameters = build_expansion_parameters(value)
172
+ elsif !@expansion_parameters_resolved
173
+ env_value = ENV.fetch(EXPANSION_PARAMETERS_ENV_VAR, nil)
174
+ @expansion_parameters = build_expansion_parameters_from_env(env_value) if env_value
175
+ end
176
+ @expansion_parameters_resolved = true
177
+
178
+ @expansion_parameters
179
+ end
180
+
181
+ # @private
182
+ # Determines whether the environment variable's content is raw JSON
183
+ # or a file path based on its first non-whitespace character.
184
+ def build_expansion_parameters_from_env(env_value)
185
+ if env_value.lstrip.start_with?('{')
186
+ build_expansion_parameters_from_json_content(env_value)
187
+ else
188
+ build_expansion_parameters(env_value)
189
+ end
190
+ end
191
+
192
+ # @private
193
+ def build_expansion_parameters(value)
194
+ case value
195
+ when Hash
196
+ build_expansion_parameters_from_json_content(value.to_json)
197
+ else
198
+ {
199
+ fileName: File.basename(value),
200
+ fileContent: File.read(value),
201
+ fileType: nil
202
+ }
203
+ end
204
+ end
205
+
206
+ # @private
207
+ def build_expansion_parameters_from_json_content(json_content)
208
+ {
209
+ fileName: 'expansion_parameters.json',
210
+ fileContent: json_content,
211
+ fileType: nil
212
+ }
213
+ end
214
+
121
215
  # @private
122
216
  # Used internally by perform_additional_validation
123
217
  def additional_validations
@@ -622,6 +716,8 @@ module Inferno
622
716
  ],
623
717
  sessionId: @session_id
624
718
  }
719
+ wrapped_resource[:expansionParameters] = expansion_parameters if expansion_parameters
720
+
625
721
  wrapped_resource.to_json
626
722
  end
627
723
  end
@@ -15,10 +15,14 @@ module Inferno
15
15
  # @param resources [Array<FHIR::Resource>]
16
16
  # @param profile_url [String]
17
17
  # @param validator_name [Symbol] Name of the FHIR Validator that references the IG the profile is in
18
- # @param metadata [Hash] MustSupport Metadata (optional),
19
- # if provided the check will use this instead of re-generating metadata from the profile
18
+ # @param metadata [Inferno::DSL::ProfileMetadata, #must_supports] MustSupport Metadata (optional),
19
+ # if provided the check will use this instead of re-generating metadata from the profile.
20
+ # Must respond to `#must_supports`, returning a Hash with `:elements`, `:extensions`, and
21
+ # `:slices` keys (and optionally `:choices` and `:recursive_elements`) -- the shape produced by
22
+ # {MustSupportMetadataExtractor#must_supports}. {Inferno::DSL::ProfileMetadata} is a base class
23
+ # test kits can subclass to build these objects, eg from generated YAML, instead of hand-rolling one.
20
24
  # @param requirement_extension [String] Extension URL that implies "required" as an alternative to the MS flag
21
- # @yield [Metadata] Customize the metadata before running the test
25
+ # @yield [MustSupportMetadataExtractor] Customize the metadata before running the test
22
26
  # @return [Array<String>] List of missing elements
23
27
  def missing_must_support_elements(resources, profile_url, validator_name: :default, metadata: nil,
24
28
  requirement_extension: nil, &)
@@ -199,6 +203,12 @@ module Inferno
199
203
  metadata.must_supports[:extensions]
200
204
  end
201
205
 
206
+ # Names of path segments that are self-referential (eg 'item' in Questionnaire.item.item)
207
+ # and so should be searched at any depth of nesting, not just the literal depth of a path.
208
+ def recursive_element_segments
209
+ Array.wrap(metadata.must_supports[:recursive_elements])
210
+ end
211
+
202
212
  def missing_extensions(resources = [])
203
213
  @missing_extensions ||=
204
214
  must_support_extensions.select do |extension_definition|
@@ -212,7 +222,7 @@ module Inferno
212
222
  normalized_extension_url(extension.url) == expected_url
213
223
  end
214
224
  else
215
- extension = find_a_value_at(resource, path) do |el|
225
+ extension = find_a_value_at(resource, path, recursive_segments: recursive_element_segments) do |el|
216
226
  normalized_extension_url(el.url) == expected_url
217
227
  end
218
228
 
@@ -243,9 +253,10 @@ module Inferno
243
253
  ms_extension_urls = must_support_extensions.select { |ex| ex[:path] == "#{raw_path}.extension" }
244
254
  .map { |ex| ex[:url] }
245
255
 
246
- value_found = find_a_value_at(resource, path) do |potential_value|
247
- matching_without_extensions?(potential_value, ms_extension_urls, element_definition[:fixed_value])
248
- end
256
+ value_found =
257
+ find_a_value_at(resource, path, recursive_segments: recursive_element_segments) do |potential_value|
258
+ matching_without_extensions?(potential_value, ms_extension_urls, element_definition[:fixed_value])
259
+ end
249
260
 
250
261
  # Note that false.present? => false, which is why we need to add this extra check
251
262
  value_found.present? || value_found == false
@@ -369,7 +380,7 @@ module Inferno
369
380
  # TODO: there is a lot of similarity
370
381
  # between this and FHIRResourceNavigation.matching_slice?
371
382
  # Can these be combined?
372
- find_a_value_at(resource, path) do |element|
383
+ find_a_value_at(resource, path, recursive_segments: recursive_element_segments) do |element|
373
384
  case discriminator[:type]
374
385
  when 'patternCodeableConcept'
375
386
  find_pattern_codeable_concept_slice(element, discriminator)
@@ -24,16 +24,49 @@ module Inferno
24
24
  self.requirement_extension_url = requirement_extension_url
25
25
  end
26
26
 
27
+ # @return [String] the canonical URL of the profile
28
+ def profile_url
29
+ profile.url
30
+ end
31
+
32
+ # @return [String] the human-readable name of the profile, eg for display purposes.
33
+ # Collapses any runs of repeated whitespace, since profile titles occasionally have them.
34
+ def profile_name
35
+ profile.title&.squeeze(' ')
36
+ end
37
+
38
+ # @return [String] the version of the profile itself (distinct from the IG's own version)
39
+ def profile_version
40
+ profile.version
41
+ end
42
+
27
43
  # Retrieval method for the must support metadata
28
44
  # @return [Hash]
29
45
  def must_supports
30
46
  @must_supports ||= {
31
47
  extensions: must_support_extensions,
32
48
  slices: must_support_slices,
33
- elements: must_support_elements
49
+ elements: must_support_elements,
50
+ recursive_elements: recursive_element_segments
34
51
  }
35
52
  end
36
53
 
54
+ # Names of path segments that are self-referential, ie, they may repeat at
55
+ # arbitrary depth. FHIR marks this with a `contentReference` back to an
56
+ # ancestor element instead of re-declaring the element's children, eg,
57
+ # Questionnaire.item.item has a contentReference of "#Questionnaire.item".
58
+ # A MustSupport flag on an element under such a segment (eg Questionnaire.item.text)
59
+ # should be considered met if it's populated at any depth of nesting
60
+ # (item.text, item.item.text, item.item.item.text, ...), not just the literal depth
61
+ # at which the flag is declared. NOTE: does not currently handle sliced recursive elements.
62
+ # @return [Array<String>]
63
+ def recursive_element_segments
64
+ profile_elements
65
+ .select { |element| element.contentReference.present? }
66
+ .map { |element| element.id.split('.').last }
67
+ .uniq
68
+ end
69
+
37
70
  def by_requirement_extension_only?(element)
38
71
  requirement_extension_url && !element.mustSupport &&
39
72
  element.extension.any? do |extension|
@@ -53,6 +86,7 @@ module Inferno
53
86
  must_support_extension_elements.map do |element|
54
87
  {
55
88
  id: element.id,
89
+ slice_name: extension_slice_name(element.id),
56
90
  path: element.path.gsub("#{resource}.", ''),
57
91
  url: canonical_url_without_version(element.type.first.profile.first),
58
92
  modifier_extension: element.path.end_with?('modifierExtension')
@@ -62,6 +96,15 @@ module Inferno
62
96
  end
63
97
  end
64
98
 
99
+ # The name of the deepest slice in a FHIR ElementDefinition id, eg "us-core-race" for
100
+ # "Patient.extension:us-core-race" or "ombCategory" for
101
+ # "Patient.extension:us-core-race.extension:ombCategory".
102
+ # @param element_id [String]
103
+ # @return [String, nil]
104
+ def extension_slice_name(element_id)
105
+ element_id.split('.').reverse.find { |segment| segment.include?(':') }&.split(':', 2)&.last
106
+ end
107
+
65
108
  def canonical_url_without_version(url)
66
109
  url&.split('|')&.first
67
110
  end
@@ -0,0 +1,113 @@
1
+ module Inferno
2
+ module DSL
3
+ # A ProfileMetadata object is a small, inheritable data holder for generated metadata about a
4
+ # profile or a group of tests -- most notably the Must Support metadata consumed by
5
+ # {MustSupportAssessment#missing_must_support_elements} and
6
+ # {Assertions#assert_must_support_elements_present} via their `metadata:` argument, alongside
7
+ # whatever other generated metadata a test kit needs to carry around (searches, bindings,
8
+ # references, etc).
9
+ #
10
+ # It exists so that test kits don't need to reach for `OpenStruct` (which accepts any attribute
11
+ # silently, making typos and generator/consumer drift hard to catch) or hand-roll their own
12
+ # version of this exact pattern.
13
+ #
14
+ # Test kits define a subclass and declare whatever attributes they need with `.attribute`.
15
+ # `must_supports` is always available, since it's the attribute Inferno's Must Support logic
16
+ # reads:
17
+ #
18
+ # class GroupMetadata < Inferno::DSL::ProfileMetadata
19
+ # attribute :searches
20
+ # attribute :bindings
21
+ # end
22
+ #
23
+ # metadata = GroupMetadata.from_file('path/to/generated/metadata.yml')
24
+ # metadata.must_supports #=> { elements: [...], extensions: [...], slices: [...] }
25
+ #
26
+ # @see MustSupportAssessment#missing_must_support_elements
27
+ class ProfileMetadata
28
+ class << self
29
+ # The full list of attribute names declared on this class and its ancestors.
30
+ # @return [Array<Symbol>]
31
+ def attribute_names
32
+ @attribute_names ||= superclass.respond_to?(:attribute_names) ? superclass.attribute_names.dup : []
33
+ end
34
+
35
+ # Declare an attribute that instances of this class (and subclasses) may be initialized with.
36
+ # @param name [Symbol]
37
+ # @return [void]
38
+ def attribute(name)
39
+ name = name.to_sym
40
+ attribute_names << name unless attribute_names.include?(name)
41
+ attr_accessor name
42
+ end
43
+
44
+ # Build an instance from a YAML file, eg one written by a metadata generator.
45
+ # @param path [String, Pathname]
46
+ # @return [ProfileMetadata]
47
+ def from_file(path)
48
+ new(YAML.load_file(path, aliases: true))
49
+ end
50
+ end
51
+
52
+ # Profile identity, mirroring fields read directly off the FHIR StructureDefinition
53
+ # (`resource` is the resource type the profile constrains, ie `profile.type`).
54
+ attribute :resource
55
+ attribute :profile_url
56
+ attribute :profile_name
57
+ attribute :profile_version
58
+
59
+ attribute :must_supports
60
+
61
+ # @param metadata [Hash] a hash of attribute name/value pairs. Keys not declared with
62
+ # `.attribute` raise an error, so that a mismatch between a metadata generator and this
63
+ # class's declared attributes is caught immediately rather than silently ignored.
64
+ def initialize(metadata = {})
65
+ metadata.each do |key, value|
66
+ key = key.to_sym
67
+ raise "Unknown #{self.class} attribute: #{key}" unless self.class.attribute_names.include?(key)
68
+
69
+ public_send(:"#{key}=", value)
70
+ end
71
+
72
+ self.must_supports ||= {}
73
+ end
74
+
75
+ # @return [Hash] the declared attributes and their current values, omitting unset (nil) ones
76
+ def to_hash
77
+ self.class.attribute_names.each_with_object({}) do |name, hash|
78
+ value = public_send(name)
79
+ hash[name] = value unless value.nil?
80
+ end
81
+ end
82
+
83
+ # Every Must Support element, slice, and extension in `must_supports`, each represented as a
84
+ # single string and sorted alphabetically. Unlike the list returned by a missing-elements
85
+ # check, this includes everything that's expected to be supported, not just what's absent
86
+ # from a particular set of resources.
87
+ #
88
+ # Elements are represented as their `path`, with the matched `fixed_value` appended after a
89
+ # colon where present (eg `"code.coding.code:45473-6"`). Slices and extensions are
90
+ # represented as their `path`, with their `slice_name` appended after a colon where present
91
+ # (eg `"category:us-core"`, `"extension:us-core-race"`).
92
+ # @return [Array<String>]
93
+ def must_support_strings
94
+ element_strings =
95
+ Array.wrap(must_supports[:elements]).map do |element|
96
+ must_support_string(element[:path], element[:fixed_value])
97
+ end
98
+ slice_strings =
99
+ Array.wrap(must_supports[:slices]).map { |slice| must_support_string(slice[:path], slice[:slice_name]) }
100
+ extension_strings =
101
+ Array.wrap(must_supports[:extensions]).map { |ext| must_support_string(ext[:path], ext[:slice_name]) }
102
+
103
+ (element_strings + slice_strings + extension_strings).sort
104
+ end
105
+
106
+ private
107
+
108
+ def must_support_string(path, suffix)
109
+ suffix.present? ? "#{path}:#{suffix}" : path
110
+ end
111
+ end
112
+ end
113
+ end
@@ -84,7 +84,10 @@ module Inferno
84
84
 
85
85
  test_run = find_test_run(test_run_identifier)
86
86
 
87
- halt 500, "Unable to find test run with identifier '#{test_run_identifier}'." if test_run.nil?
87
+ if test_run.nil?
88
+ halt 500, "Unable to find test run with identifier '#{test_run_identifier}' " \
89
+ "on request to '#{request.url}'."
90
+ end
88
91
 
89
92
  test_runs_repo.mark_as_no_longer_waiting(test_run.id)
90
93