strata-cli 0.1.8 → 0.1.10

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +26 -2
  4. data/lib/strata/cli/agent_mode.rb +26 -0
  5. data/lib/strata/cli/agent_output.rb +21 -0
  6. data/lib/strata/cli/ai/services/table_generator.rb +35 -20
  7. data/lib/strata/cli/api/client.rb +23 -63
  8. data/lib/strata/cli/api/response_error_handler.rb +115 -0
  9. data/lib/strata/cli/error_reporter.rb +4 -1
  10. data/lib/strata/cli/generators/datasource.rb +4 -3
  11. data/lib/strata/cli/generators/group.rb +37 -0
  12. data/lib/strata/cli/generators/migration.rb +2 -1
  13. data/lib/strata/cli/generators/project.rb +18 -11
  14. data/lib/strata/cli/generators/relation.rb +2 -1
  15. data/lib/strata/cli/generators/table.rb +5 -8
  16. data/lib/strata/cli/generators/templates/AGENTS.md +457 -88
  17. data/lib/strata/cli/generators/templates/table.table_name.yml +8 -3
  18. data/lib/strata/cli/generators/test.rb +2 -1
  19. data/lib/strata/cli/guard.rb +4 -1
  20. data/lib/strata/cli/helpers/command_context.rb +8 -9
  21. data/lib/strata/cli/helpers/datasource_helper.rb +27 -1
  22. data/lib/strata/cli/helpers/description_helper.rb +2 -1
  23. data/lib/strata/cli/main.rb +12 -3
  24. data/lib/strata/cli/output.rb +103 -0
  25. data/lib/strata/cli/sub_commands/audit.rb +136 -16
  26. data/lib/strata/cli/sub_commands/branch.rb +165 -0
  27. data/lib/strata/cli/sub_commands/create.rb +13 -2
  28. data/lib/strata/cli/sub_commands/datasource.rb +21 -3
  29. data/lib/strata/cli/sub_commands/deploy.rb +16 -13
  30. data/lib/strata/cli/sub_commands/project.rb +6 -3
  31. data/lib/strata/cli/sub_commands/table.rb +11 -8
  32. data/lib/strata/cli/terminal.rb +7 -4
  33. data/lib/strata/cli/ui/field_editor.rb +21 -27
  34. data/lib/strata/cli/utils/deployment_monitor.rb +15 -34
  35. data/lib/strata/cli/utils/git.rb +78 -0
  36. data/lib/strata/cli/utils/import_manager.rb +4 -1
  37. data/lib/strata/cli/utils/test_reporter.rb +4 -32
  38. data/lib/strata/cli/utils/version_checker.rb +4 -8
  39. data/lib/strata/cli/utils.rb +3 -1
  40. data/lib/strata/cli/version.rb +1 -1
  41. data/lib/strata/cli.rb +4 -3
  42. metadata +6 -2
  43. data/lib/strata/cli/helpers/color_helper.rb +0 -103
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "utils"
4
+ require_relative "output"
5
+
4
6
  module Strata
5
7
  module CLI
6
8
  module Guard
@@ -10,13 +12,14 @@ module Strata
10
12
  adapters
11
13
  version
12
14
  deploy
15
+ branch
13
16
  ].freeze
14
17
 
15
18
  def invoke_command(command, *args)
16
19
  Utils.exit_error_if_not_strata! unless ALLOWED_COMMANDS.include?(command.name)
17
20
  super
18
21
  rescue Strata::CommandError => e
19
- shell.say_error "ERROR: #{e.message}", :red
22
+ Output.print_error("ERROR: #{e.message}", context: self)
20
23
  exit 1
21
24
  end
22
25
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "tty-prompt"
4
4
  require_relative "datasource_helper"
5
- require_relative "color_helper"
5
+ require_relative "../output"
6
6
  require_relative "prompts"
7
7
  require_relative "../error_reporter"
8
8
 
@@ -10,6 +10,7 @@ module Strata
10
10
  module CLI
11
11
  module Helpers
12
12
  module CommandContext
13
+ include Output
13
14
  include DatasourceHelper
14
15
 
15
16
  def adapter
@@ -28,8 +29,7 @@ module Strata
28
29
  @table_fetch_result ||= begin
29
30
  tables = with_spinner("Fetching tables from #{datasource_key}...") { adapter.tables }
30
31
  if tables.empty?
31
- # Assuming Prompts and ColorHelper are available in the class or via module
32
- say Prompts::MSG_NO_TABLES_FOUND % datasource_key, ColorHelper.warning
32
+ say(Output.format(:warning, Prompts::MSG_NO_TABLES_FOUND % datasource_key), nil) if respond_to?(:say)
33
33
  {tables: [], failed: false}
34
34
  else
35
35
  {tables: tables, failed: false}
@@ -38,15 +38,14 @@ module Strata
38
38
  ErrorReporter.log_error(e, context: "create table: failed fetching tables for '#{datasource_key}'")
39
39
 
40
40
  if ErrorReporter.connection_error?(e)
41
- say "Could not connect to datasource '#{datasource_key}'.", ColorHelper.warning
41
+ say(Output.format(:warning, "Could not connect to datasource '#{datasource_key}'."), nil) if respond_to?(:say)
42
42
  else
43
- say "Could not fetch tables from datasource '#{datasource_key}'.", ColorHelper.warning
44
- say "Reason: #{ErrorReporter.user_message_for(e)}", ColorHelper.warning
43
+ say(Output.format(:warning, "Could not fetch tables from datasource '#{datasource_key}'."), nil) if respond_to?(:say)
44
+ say(Output.format(:warning, "Reason: #{ErrorReporter.user_message_for(e)}"), nil) if respond_to?(:say)
45
45
  end
46
46
 
47
- say "Hint: verify credentials/settings and run 'strata datasource test #{datasource_key}'.",
48
- ColorHelper.info
49
- say "Details logged to '#{ErrorReporter.log_relative_path}'.", ColorHelper.info
47
+ say(Output.format(:dim, "Hint: verify credentials/settings and run 'strata datasource test #{datasource_key}'."), nil) if respond_to?(:say)
48
+ say(Output.format(:info, "Details logged to '#{ErrorReporter.log_relative_path}'."), nil) if respond_to?(:say)
50
49
  {tables: [], failed: true}
51
50
  end
52
51
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../agent_output"
4
+
3
5
  module Strata
4
6
  module CLI
5
7
  module DatasourceHelper
@@ -8,7 +10,7 @@ module Strata
8
10
  redshift: %w[pg],
9
11
  mysql: %w[mysql2],
10
12
  sqlserver: %w[tiny_tds],
11
- athena: %w[aws-sdk-athena aws-sdk-s3],
13
+ athena: %w[aws-sdk-athena aws-sdk-s3 rexml],
12
14
  trino: %w[trino-client],
13
15
  sqlite: %w[sqlite3],
14
16
  duckdb: %w[duckdb]
@@ -24,6 +26,19 @@ module Strata
24
26
  # 3. Check available datasources
25
27
  ds_keys = datasources.keys
26
28
 
29
+ if agent_mode?
30
+ if ds_keys.empty?
31
+ agent_emit_no_datasources!
32
+ elsif ds_keys.length == 1
33
+ return ds_keys.first
34
+ else
35
+ AgentOutput.emit_error(
36
+ "Datasource key required. Available: #{ds_keys.join(", ")}",
37
+ code: "datasource_key_required"
38
+ )
39
+ end
40
+ end
41
+
27
42
  if ds_keys.empty?
28
43
  say "No datasources configured. Run 'strata datasource add' first.", :red
29
44
  nil
@@ -134,6 +149,17 @@ module Strata
134
149
 
135
150
  private
136
151
 
152
+ def agent_emit_no_datasources!
153
+ AgentOutput.emit_error(
154
+ "No datasources configured in datasources.yml. Ask the user to add one interactively — " \
155
+ "do not create datasources or write credentials yourself (secrets belong in .strata via CLI prompts). " \
156
+ "User command: strata datasource add [ADAPTER] (e.g. strata datasource add postgres, strata datasource add duckdb).",
157
+ code: "no_datasources",
158
+ user_action_required: true,
159
+ suggested_command: "strata datasource add [ADAPTER]"
160
+ )
161
+ end
162
+
137
163
  def validate_datasource(ds_key)
138
164
  unless datasources[ds_key]
139
165
  say "Error: Datasource '#{ds_key}' not found in datasources.yml", :red
@@ -11,7 +11,8 @@ module Strata
11
11
  if File.exist?(file_path)
12
12
  long_desc File.read(file_path)
13
13
  else
14
- warn "Warning: Description file not found at #{file_path}"
14
+ require_relative "../output"
15
+ Output.print_warning("Warning: Description file not found at #{file_path}")
15
16
  end
16
17
  end
17
18
  end
@@ -3,16 +3,21 @@
3
3
  require_relative "generators/project"
4
4
  require_relative "sub_commands/datasource"
5
5
  require_relative "sub_commands/deploy"
6
+ require_relative "sub_commands/branch"
6
7
  require_relative "sub_commands/project"
7
8
  require_relative "sub_commands/table"
8
9
  require_relative "sub_commands/create"
9
10
  require_relative "sub_commands/audit"
10
11
  require_relative "helpers/description_helper"
12
+ require_relative "agent_mode"
13
+ require_relative "output"
11
14
 
12
15
  module Strata
13
16
  module CLI
14
17
  class Main < Thor
15
18
  include Guard
19
+ include Output
20
+ include AgentMode
16
21
  extend Helpers::DescriptionHelper
17
22
 
18
23
  def self.exit_on_failure?
@@ -30,21 +35,22 @@ module Strata
30
35
  repeatable: true
31
36
  option :source, aliases: ["s"], type: :string, desc: "URL of existing project"
32
37
  option :api_key, aliases: ["a"], type: :string, desc: "Api Key. Required if initializing existing project."
38
+ option :verbose, aliases: ["v"], type: :boolean, default: false,
39
+ desc: "Show detailed init output (file-by-file actions)."
33
40
 
34
41
  def init(project_name = nil)
35
42
  unless project_name || options[:source]
36
43
  raise Strata::CommandError, "PROJECT_NAME is required when not using --source option."
37
44
  end
38
45
 
39
- say_status :started, "Creating #{project_name || "project from source"} - #{options[:datasource]}",
40
- ColorHelper.info
46
+ print_status(:created, (project_name || "project from source").to_s, type: :info)
41
47
  invoke Generators::Project, [project_name], options
42
48
  end
43
49
 
44
50
  desc "adapters", "Lists supported data warehouse adapters"
45
51
  def adapters
46
52
  out = " SUPPORTED ADAPTERS: \n\t#{DWH.adapters.keys.join("\n\t")}"
47
- say out, ColorHelper.info
53
+ print_info(out)
48
54
  end
49
55
 
50
56
  desc "datasource", "Manage project datasources"
@@ -62,6 +68,9 @@ module Strata
62
68
  desc "deploy", "Deploy project to Strata server"
63
69
  subcommand "deploy", SubCommands::Deploy
64
70
 
71
+ desc "branch", "Manage Strata server branches"
72
+ subcommand "branch", SubCommands::Branch
73
+
65
74
  desc "project", "Manage project configuration"
66
75
  subcommand "project", SubCommands::Project
67
76
 
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "pastel"
5
+
6
+ module Strata
7
+ module CLI
8
+ module Output
9
+ THEME = {
10
+ success: :green,
11
+ error: %i[red bold],
12
+ warning: :yellow,
13
+ info: :cyan,
14
+ title: %i[cyan bold],
15
+ highlight: %i[cyan bold],
16
+ dim: :bright_black,
17
+ primary: :blue,
18
+ secondary: :magenta,
19
+ border: %i[cyan dim],
20
+ selected: %i[green bold],
21
+ disabled: :bright_black
22
+ }.freeze
23
+
24
+ class << self
25
+ def pastel
26
+ @pastel ||= Pastel.new(enabled: $stdout.tty?)
27
+ end
28
+
29
+ def shell_for(context = nil)
30
+ return context.shell if context&.respond_to?(:shell)
31
+ return context if context.is_a?(Thor::Shell)
32
+
33
+ Thor::Shell::Color.new
34
+ end
35
+
36
+ def print_info(message, context: nil)
37
+ shell_for(context).say(format(:info, message))
38
+ end
39
+
40
+ def print_success(message, context: nil)
41
+ shell_for(context).say(format(:success, message))
42
+ end
43
+
44
+ def print_warning(message, context: nil)
45
+ shell_for(context).say(format(:warning, message))
46
+ end
47
+
48
+ def print_error(message, context: nil)
49
+ shell_for(context).say_error(format(:error, message))
50
+ end
51
+
52
+ def print_hint(message, context: nil, stderr: false)
53
+ shell = shell_for(context)
54
+ formatted = format(:dim, message)
55
+ stderr ? shell.say_error(formatted) : shell.say(formatted)
56
+ end
57
+
58
+ # Standard status output with Thor's prefix formatting.
59
+ # We keep the label color via Thor, but decorate the message via Output theme.
60
+ def print_status(label, message, type: :info, context: nil)
61
+ shell = shell_for(context)
62
+ shell.say_status(label, format(type, message), thor_color(type))
63
+ end
64
+
65
+ def format(type, message)
66
+ return "" if message.nil?
67
+
68
+ styles = THEME[type]
69
+ return message.to_s unless styles
70
+
71
+ if styles.is_a?(Array)
72
+ pastel.decorate(message.to_s, *styles)
73
+ else
74
+ pastel.send(styles, message.to_s)
75
+ end
76
+ end
77
+
78
+ # Thor only understands a limited palette; keep it simple/stable.
79
+ def thor_color(type)
80
+ case type
81
+ when :success then :green
82
+ when :error then :red
83
+ when :warning then :yellow
84
+ when :info, :title, :border then :cyan
85
+ when :primary then :blue
86
+ when :secondary then :magenta
87
+ when :dim then :white
88
+ else
89
+ :white
90
+ end
91
+ end
92
+ end
93
+
94
+ # Convenience instance methods for Thor classes.
95
+ def print_info(message) = Output.print_info(message, context: self)
96
+ def print_success(message) = Output.print_success(message, context: self)
97
+ def print_warning(message) = Output.print_warning(message, context: self)
98
+ def print_error(message) = Output.print_error(message, context: self)
99
+ def print_hint(message, stderr: false) = Output.print_hint(message, context: self, stderr: stderr)
100
+ def print_status(label, message, type: :info) = Output.print_status(label, message, type: type, context: self)
101
+ end
102
+ end
103
+ end
@@ -3,8 +3,9 @@
3
3
  require_relative "../guard"
4
4
  require_relative "../terminal"
5
5
  require_relative "../credentials"
6
- require_relative "../helpers/color_helper"
6
+ require_relative "../output"
7
7
  require_relative "../helpers/datasource_helper"
8
+ require_relative "../agent_mode"
8
9
  require_relative "../utils/yaml_import_resolver"
9
10
  require_relative "../utils/import_manager"
10
11
  require "yaml"
@@ -17,12 +18,22 @@ module Strata
17
18
  include Thor::Actions
18
19
  include Guard
19
20
  include Terminal
21
+ include Output
20
22
  include DatasourceHelper
23
+ include AgentMode
21
24
 
22
25
  REQUIRED_KEYS_FOR_TABLE_MODEL = %w[name physical_name fields datasource].freeze
23
26
  REQUIRED_KEYS_FOR_RELATIONSHIP_MODEL = ["datasource"].freeze
24
27
  REQUIRED_KEYS_FOR_RELATIONSHIP_DEFINITION = %w[left right sql cardinality].freeze
25
28
  RELATIONSHIP_CARDINALITIES = %w[one_to_one one_to_many many_to_one many_to_many].freeze
29
+ VALID_FORMAT_TYPES = %w[raw number currency percent date datetime html javascript].freeze
30
+ ALLOWED_FIELD_KEYS = %w[
31
+ type name description hidden grains data_type display_type format
32
+ secure disable_listing value_list_size snapshot exclusion_type
33
+ exclusions inclusions extended_blend_group synonyms expression
34
+ ].freeze
35
+ ALLOWED_EXPRESSION_KEYS = %w[sql array lookup primary_key].freeze
36
+ EXPRESSION_BOOLEAN_KEYS = %w[array lookup primary_key].freeze
26
37
 
27
38
  # Set default command so `strata audit` still works as `strata audit all`
28
39
  default_command :all
@@ -61,7 +72,7 @@ module Strata
61
72
  if results.values.all?
62
73
  # All checks passed - no need for additional message, spinners already show success
63
74
  else
64
- say "\n Some checks failed.", ColorHelper.error
75
+ print_error("\n Some checks failed.")
65
76
  exit(1)
66
77
  end
67
78
  end
@@ -76,7 +87,7 @@ module Strata
76
87
  rescue
77
88
  failures.each do |f|
78
89
  msg = f.is_a?(Hash) ? "#{f[:file]}: #{f[:message]}" : f.to_s
79
- say " ✖ #{msg}", ColorHelper.error
90
+ print_error(" ✖ #{msg}")
80
91
  end
81
92
  false
82
93
  end
@@ -98,8 +109,8 @@ module Strata
98
109
  def audit_models
99
110
  failures = []
100
111
  Dir.glob("models/**/*.yml").each do |file|
101
- audit_model_file(file, failures)
102
112
  audit_imports(file, failures)
113
+ audit_model_file(file, failures)
103
114
  end
104
115
  failures
105
116
  end
@@ -108,17 +119,21 @@ module Strata
108
119
  content = YAML.safe_load_file(file, permitted_classes: [Date, Time], aliases: true) || {}
109
120
  return unless content.is_a?(Hash)
110
121
 
111
- validate_structure(content, file, failures)
112
-
113
122
  case model_type(file)
114
123
  when :table
115
- validate_table_model(content, file, failures)
124
+ resolved = Utils::YamlImportResolver.resolve(file, Dir.pwd)
125
+ return unless resolved.is_a?(Hash)
126
+
127
+ validate_structure(resolved, file, failures)
128
+ validate_table_model(resolved, file, failures)
116
129
  when :relationship
130
+ validate_structure(content, file, failures)
117
131
  validate_relationship_model(content, file, failures)
118
132
  end
119
- rescue
120
- # Ignore errors here, they are handled in audit_yaml_syntax if needed
121
- # or simply skipped if the file is unreadable
133
+ rescue Strata::ImportError => e
134
+ failures << {file: file, message: e.message}
135
+ rescue Psych::SyntaxError
136
+ # Handled by audit_yaml_syntax when run
122
137
  end
123
138
 
124
139
  def model_type(file)
@@ -139,7 +154,7 @@ module Strata
139
154
  def validate_table_model(content, file, failures)
140
155
  validate_required_keys(content, file, REQUIRED_KEYS_FOR_TABLE_MODEL, failures)
141
156
  validate_datasource_reference(content, file, failures)
142
- validate_table_fields(content["fields"], file, failures) if content.key?("fields")
157
+ validate_table_fields(content["fields"], file, failures)
143
158
  end
144
159
 
145
160
  def validate_relationship_model(content, file, failures)
@@ -171,10 +186,119 @@ module Strata
171
186
  return
172
187
  end
173
188
 
189
+ if fields.empty?
190
+ failures << {file: file, message: "'fields' must not be empty"}
191
+ return
192
+ end
193
+
174
194
  fields.each_with_index do |field, idx|
175
195
  unless field.is_a?(Hash) && field.key?("name")
176
196
  failures << {file: file, message: "Field at index #{idx} missing 'name'"}
197
+ next
198
+ end
199
+
200
+ validate_field_definition(field, file, idx, failures)
201
+ end
202
+ end
203
+
204
+ def validate_field_definition(field, file, idx, failures)
205
+ field_name = field["name"] || "index #{idx}"
206
+
207
+ unknown_keys = field.keys.map(&:to_s) - ALLOWED_FIELD_KEYS
208
+ if unknown_keys.any?
209
+ failures << {
210
+ file: file,
211
+ message: "Field '#{field_name}' has unknown #{(unknown_keys.size == 1) ? "key" : "keys"}: #{unknown_keys.sort.join(", ")}"
212
+ }
213
+ end
214
+
215
+ validate_field_expression(field_name, field["expression"], file, failures)
216
+
217
+ return unless field.key?("format")
218
+
219
+ validate_field_format(field_name, field["format"], file, failures)
220
+ end
221
+
222
+ def validate_field_expression(field_name, expression, file, failures)
223
+ if expression.nil?
224
+ failures << {file: file, message: "Field '#{field_name}' missing required 'expression'"}
225
+ return
226
+ end
227
+
228
+ if expression.is_a?(String)
229
+ if expression.strip.empty?
230
+ failures << {file: file, message: "Field '#{field_name}' expression must not be empty"}
231
+ end
232
+ return
233
+ end
234
+
235
+ unless expression.is_a?(Hash)
236
+ type_name = expression.class.name
237
+ failures << {
238
+ file: file,
239
+ message: "Field '#{field_name}' expression must be a string shortcut or a mapping (got #{type_name})"
240
+ }
241
+ return
242
+ end
243
+
244
+ unknown_keys = expression.keys.map(&:to_s) - ALLOWED_EXPRESSION_KEYS
245
+ if unknown_keys.any?
246
+ failures << {
247
+ file: file,
248
+ message: "Field '#{field_name}' expression has unknown #{(unknown_keys.size == 1) ? "key" : "keys"}: " \
249
+ "#{unknown_keys.sort.join(", ")}"
250
+ }
251
+ end
252
+
253
+ sql = expression["sql"] || expression[:sql]
254
+ if sql.nil? || sql.to_s.strip.empty?
255
+ failures << {file: file, message: "Field '#{field_name}' expression must include non-empty 'sql'"}
256
+ end
257
+
258
+ EXPRESSION_BOOLEAN_KEYS.each do |key|
259
+ next unless expression.key?(key) || expression.key?(key.to_sym)
260
+
261
+ value = expression[key] || expression[key.to_sym]
262
+ next if value == true || value == false
263
+
264
+ failures << {
265
+ file: file,
266
+ message: "Field '#{field_name}' expression.#{key} must be true or false"
267
+ }
268
+ end
269
+ end
270
+
271
+ def validate_field_format(field_name, format_value, file, failures)
272
+ case format_value
273
+ when String
274
+ shortcut = format_value.strip
275
+ if shortcut.empty?
276
+ failures << {file: file, message: "Field '#{field_name}' format must not be empty"}
277
+ return
278
+ end
279
+
280
+ type = shortcut.split(":", 2).first
281
+ unless VALID_FORMAT_TYPES.include?(type)
282
+ failures << {
283
+ file: file,
284
+ message: "Field '#{field_name}' format shortcut has invalid type '#{type}' (expected one of: #{VALID_FORMAT_TYPES.join(", ")})"
285
+ }
286
+ end
287
+ when Hash
288
+ type = format_value["type"] || format_value[:type]
289
+ if type.nil? || type.to_s.strip.empty?
290
+ failures << {file: file, message: "Field '#{field_name}' format hash must include type"}
291
+ elsif !VALID_FORMAT_TYPES.include?(type.to_s)
292
+ failures << {
293
+ file: file,
294
+ message: "Field '#{field_name}' format hash has invalid type '#{type}' (expected one of: #{VALID_FORMAT_TYPES.join(", ")})"
295
+ }
177
296
  end
297
+ else
298
+ failures << {
299
+ file: file,
300
+ message: "Field '#{field_name}' format must be a string shortcut or a hash with type"
301
+ }
178
302
  end
179
303
  end
180
304
 
@@ -253,13 +377,9 @@ module Strata
253
377
  imported_content = YAML.safe_load_file(resolved_path, permitted_classes: [Date, Time], aliases: true) || {}
254
378
 
255
379
  unless imported_content.is_a?(Hash)
256
- failures << {file: file, message: "Imported file '#{import_path}' does not contain valid YAML hash"}
380
+ failures << {file: file, message: "Imported file '#{import_path}' does not contain valid YAML"}
257
381
  next
258
382
  end
259
-
260
- if model_type(file) == :table && imported_content["fields"]
261
- validate_table_fields(imported_content["fields"], file, failures)
262
- end
263
383
  rescue Strata::InvalidImportPathError => e
264
384
  failures << {file: file, message: e.message}
265
385
  rescue Strata::MissingImportError