gdharness 1.1.4 → 1.1.6

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.
@@ -4,12 +4,45 @@ const Read = preload("reading.gd")
4
4
  const FileWalk = preload("file_walk.gd")
5
5
  const Log = preload("logger.gd")
6
6
 
7
+ ## What the engine imports with no tool outside it, for finding what has not been imported yet;
8
+ ## everything imported already is found by its sidecar whatever its extension.
7
9
  const IMPORTABLE_EXTENSIONS: Array[String] = [
8
- "png", "jpg", "jpeg", "webp", "svg", "wav", "mp3", "ogg", "ttf", "otf", "glb", "gltf", "fbx", "obj"
10
+ "png",
11
+ "jpg",
12
+ "jpeg",
13
+ "webp",
14
+ "svg",
15
+ "bmp",
16
+ "tga",
17
+ "exr",
18
+ "hdr",
19
+ "dds",
20
+ "ktx",
21
+ "wav",
22
+ "mp3",
23
+ "ogg",
24
+ "ttf",
25
+ "otf",
26
+ "ttc",
27
+ "otc",
28
+ "woff",
29
+ "woff2",
30
+ "pfb",
31
+ "pfm",
32
+ "fnt",
33
+ "font",
34
+ "glb",
35
+ "gltf",
36
+ "fbx",
37
+ "obj",
38
+ "dae",
9
39
  ]
40
+ const EDITOR_DIRECTORY: String = "res://.godot/editor"
41
+ const EDITOR_CACHE_PREFIX: String = "filesystem_cache"
10
42
 
11
43
  var _log: Log
12
44
  var _files: FileWalk = FileWalk.new()
45
+ var _sidecar_md5s: Variant = null
13
46
 
14
47
 
15
48
  func _init(p_log: Log) -> void:
@@ -29,7 +62,9 @@ func get_import_status(params: Dictionary) -> Dictionary:
29
62
  )
30
63
 
31
64
  var resources: Array[Dictionary] = []
32
- var summary: Dictionary = {"total": 0, "needs_reimport": 0, "up_to_date": 0, "missing_source": 0}
65
+ var summary: Dictionary = {
66
+ "total": 0, "needs_reimport": 0, "failed": 0, "up_to_date": 0, "missing_source": 0
67
+ }
33
68
 
34
69
  if not resource_path.is_empty():
35
70
  var full_path: String = resource_path
@@ -40,7 +75,17 @@ func get_import_status(params: Dictionary) -> Dictionary:
40
75
  resources.append(status)
41
76
  _tally(summary, status)
42
77
  else:
78
+ # Sources by extension, for what has never been imported, and sidecars, for what was imported
79
+ # from a type the list does not name and for a sidecar whose source is gone.
80
+ var found: Dictionary[String, bool] = {}
43
81
  for res_path: String in _files.find_files_with_extensions("res://", IMPORTABLE_EXTENSIONS):
82
+ found[res_path] = true
83
+ for sidecar: String in _files.find_files_with_extensions("res://", ["import"]):
84
+ found[sidecar.trim_suffix(".import")] = true
85
+ var paths: Array[String] = []
86
+ paths.assign(found.keys())
87
+ paths.sort()
88
+ for res_path: String in paths:
44
89
  var status: Dictionary = _import_status_of(res_path, res_path + ".import")
45
90
 
46
91
  if include_up_to_date or status["status"] != "up_to_date":
@@ -91,7 +136,6 @@ func set_import_options(params: Dictionary) -> Dictionary:
91
136
  resource_path = "res://" + resource_path
92
137
 
93
138
  var options: Dictionary = params.get("options", {})
94
- var do_reimport: bool = Read.as_bool(params.get("reimport", true), true)
95
139
 
96
140
  _log.info("Setting import options for: " + resource_path)
97
141
 
@@ -110,56 +154,28 @@ func set_import_options(params: Dictionary) -> Dictionary:
110
154
  var updated_keys: Array[String] = []
111
155
  for key: Variant in options:
112
156
  var name: String = str(key)
113
- config.set_value("params", name, options[key])
157
+ var value: Variant = _as_option(options[key], config.get_value("params", name, null))
158
+ config.set_value("params", name, value)
114
159
  updated_keys.append(name)
115
- _log.debug("Set " + name + " = " + str(options[key]))
160
+ _log.debug("Set " + name + " = " + str(value))
116
161
 
117
162
  err = config.save(import_file_path)
118
163
  if err != OK:
119
164
  return _log.failure("Failed to save import file: " + str(err))
120
165
 
121
- var result: Dictionary = {
122
- "resource_path": resource_path, "updated_options": updated_keys, "reimport_triggered": do_reimport
123
- }
124
-
125
- # A headless engine has no importer to run, so the .import file is as far as this goes.
126
- if do_reimport:
127
- result["note"] = "Import file updated. Run the editor or use 'reimport_resource' to apply changes."
128
-
129
- return result
130
-
131
-
132
- # Reimport a resource or all resources
133
- func reimport_resource(params: Dictionary) -> Dictionary:
134
- var resource_path: String = str(params.get("resource_path", ""))
135
- var force: bool = Read.as_bool(params.get("force", false))
136
-
137
- _log.info(
138
- (
139
- "Reimporting"
140
- + (" resource: " + resource_path if not resource_path.is_empty() else " all modified resources")
141
- )
142
- )
143
-
144
- # A full reimport is editor work; headless can only report what the state is.
145
- var result: Dictionary = {
146
- "status": "requested",
147
- "resource_path": resource_path if not resource_path.is_empty() else "all",
148
- "force": force,
149
- "note": "Reimport in headless mode is limited. For full reimport, open the project in the editor."
150
- }
151
-
152
- if not resource_path.is_empty():
153
- var full_path: String = resource_path
154
- if not full_path.begins_with("res://"):
155
- full_path = "res://" + full_path
156
-
157
- if not FileAccess.file_exists(full_path):
158
- return _log.failure("Resource file does not exist: " + full_path)
166
+ # The reimport that applies them is the server's, through the editor or the engine's import.
167
+ return {"resource_path": resource_path, "updated_options": updated_keys}
159
168
 
160
- result["current_status"] = _import_status_of(full_path, full_path + ".import")["status"]
161
169
 
162
- return result
170
+ ## [param given] as the option holds it. Every JSON number arrives as a float, and an enum option
171
+ ## such as compress/mode written as 1.0 is kept that way by the import, so a whole number becomes
172
+ ## an int unless the option already holds a float.
173
+ static func _as_option(given: Variant, held: Variant) -> Variant:
174
+ if given is float and not held is float:
175
+ var number: float = given
176
+ if number == floorf(number):
177
+ return int(number)
178
+ return given
163
179
 
164
180
 
165
181
  # List export presets
@@ -367,7 +383,7 @@ func _import_status_of(resource_path: String, import_file_path: String) -> Dicti
367
383
  return {
368
384
  "path": resource_path,
369
385
  "status": "missing_source",
370
- "import_file_exists": false,
386
+ "import_file_exists": FileAccess.file_exists(import_file_path),
371
387
  "source_exists": false
372
388
  }
373
389
 
@@ -376,6 +392,7 @@ func _import_status_of(resource_path: String, import_file_path: String) -> Dicti
376
392
  return {
377
393
  "path": resource_path,
378
394
  "status": "needs_reimport",
395
+ "reason": "it has not been imported",
379
396
  "import_file_exists": false,
380
397
  "source_exists": true
381
398
  }
@@ -384,80 +401,228 @@ func _import_status_of(resource_path: String, import_file_path: String) -> Dicti
384
401
  "path": resource_path, "status": "up_to_date", "import_file_exists": true, "source_exists": true
385
402
  }
386
403
 
404
+ # The checks are the ones EditorFileSystem::_test_for_reimport makes, in its order, so a resource
405
+ # reads as needing an import exactly when the editor's next scan would import it.
406
+ var sidecar: ConfigFile = ConfigFile.new()
407
+ var stale: Dictionary = (
408
+ _stale("its import file cannot be read")
409
+ if sidecar.load(import_file_path) != OK
410
+ else _sidecar_staleness(resource_path, import_file_path, sidecar)
411
+ )
412
+ var kept: bool = str(sidecar.get_value("remap", "importer", "")) in ["keep", "skip"]
413
+ if stale.is_empty() and not kept:
414
+ stale = _import_staleness(resource_path, sidecar)
415
+ status.merge(stale, true)
416
+ return status
417
+
418
+
419
+ static func _stale(reason: String) -> Dictionary:
420
+ return {"status": "needs_reimport", "reason": reason}
421
+
422
+
423
+ ## What the editor reads off the sidecar before looking at the import: an answer for a stale or
424
+ ## failed resource, empty otherwise.
425
+ func _sidecar_staleness(resource_path: String, import_file_path: String, sidecar: ConfigFile) -> Dictionary:
426
+ # The editor compares the sidecar with the one it last imported from before reading anything in
427
+ # it, so an option set without a reimport is imported on its next scan, and not before.
428
+ var imported_from: String = _imported_sidecar_md5s().get(resource_path, "")
429
+ if not imported_from.is_empty() and FileAccess.get_md5(import_file_path) != imported_from:
430
+ return _stale("its import file changed after it was imported, as setting an option does")
431
+
432
+ # A failed import is never tried again on its own: the editor skips it to avoid a loop of
433
+ # reimports, so it stays failed after its source is fixed until something asks for a reimport.
434
+ var valid: Variant = sidecar.get_value("remap", "valid", true)
435
+ if valid is bool and not valid:
436
+ return {
437
+ "status": "failed",
438
+ "reason": "its last import failed, and the editor does not try a failed import again on its own",
439
+ }
440
+ return {}
441
+
442
+
443
+ ## Whether the import itself is still the one its sidecar and source describe: an answer for a
444
+ ## stale resource, empty for a current one.
445
+ static func _import_staleness(resource_path: String, sidecar: ConfigFile) -> Dictionary:
446
+ if not sidecar.has_section_key("remap", "uid"):
447
+ return _stale("its import file has no uid, which the import writes")
448
+
387
449
  # The sidecar alone does not say the import is there: deleting the outputs under
388
450
  # .godot/imported is the ordinary way to force one, and a resource whose output is gone was
389
451
  # answered as current, which is what a reimport is then skipped on.
390
- var outputs: Array[String] = _outputs_of(import_file_path)
452
+ var outputs: Array[String] = _outputs_of(sidecar)
391
453
  var missing: Array[String] = []
392
454
  for output: String in outputs:
393
455
  if not FileAccess.file_exists(output):
394
456
  missing.append(output)
395
457
  if not missing.is_empty():
396
- status["status"] = "needs_reimport"
397
- status["reason"] = "its imported output is not on disk"
398
- status["missing_outputs"] = missing
399
- return status
458
+ var gone: Dictionary = _stale("its imported output is not on disk")
459
+ gone["missing_outputs"] = missing
460
+ return gone
461
+
462
+ var source_file: String = str(sidecar.get_value("deps", "source_file", ""))
463
+ if not source_file.is_empty() and source_file != resource_path:
464
+ return _stale("its import file was written for " + source_file)
400
465
 
401
466
  # By content, as the editor judges it: the hash of the source the import recorded, against the
402
467
  # file. By time, an installer that rewrote 31 images byte for byte made every one read as changed,
403
468
  # and the editor, which compares the hash, rightly imported none of them.
404
- var recorded: String = _recorded_source_md5(resource_path)
405
- var changed: bool = (
406
- FileAccess.get_md5(resource_path) != recorded
407
- if not recorded.is_empty()
408
- else FileAccess.get_modified_time(resource_path) > FileAccess.get_modified_time(import_file_path)
409
- )
410
- if changed:
411
- status["status"] = "needs_reimport"
412
- status["reason"] = "the source changed after it was imported"
413
- return status
469
+ var recorded: Dictionary = _recorded_md5s(resource_path)
470
+ var recorded_source: String = recorded.get("source_md5", "")
471
+ if recorded_source.is_empty():
472
+ return _stale("its import recorded no hash of its source")
473
+ if FileAccess.get_md5(resource_path) != recorded_source:
474
+ return _stale("the source changed after it was imported")
475
+
476
+ var recorded_outputs: String = recorded.get("dest_md5", "")
477
+ if (
478
+ not outputs.is_empty()
479
+ and not recorded_outputs.is_empty()
480
+ and _md5_of_all(outputs) != recorded_outputs
481
+ ):
482
+ return _stale("its imported output changed after it was imported")
414
483
 
415
484
  # A scene built without an image it names: it loads each one as it imports, so one imported
416
485
  # before the image was is built without it. Read off what the imported scene depends on, not off
417
486
  # file times: a texture a 3D scene uses is reimported compressed after the scene, so its outputs
418
487
  # are always the newer, and 33 correct scenes read as stale when judged that way.
488
+ #
489
+ # Followed through the resources the scene depends on, since a post-import script that swaps each
490
+ # material for a saved one leaves the scene depending on the material and only the material on
491
+ # the image: 211 correct scenes read as stale when only the scene's own list was read. And not
492
+ # held against a scene an import script shaped at all, since the script may replace a material
493
+ # with one using other images entirely, which is the scene it was meant to build.
419
494
  var without: Array[String] = []
420
495
  var images: Array[String] = _images_of(resource_path)
421
- if not images.is_empty():
422
- var depended: PackedStringArray = ResourceLoader.get_dependencies(resource_path)
496
+ if not images.is_empty() and not _shaped_by_a_script(sidecar):
497
+ var depended: Dictionary[String, bool] = _depended_on(resource_path)
423
498
  for image: String in images:
424
- if FileAccess.file_exists(image + ".import") and not _names(depended, image):
499
+ if FileAccess.file_exists(image + ".import") and not depended.has(image):
425
500
  without.append(image)
426
- if not without.is_empty():
427
- status["status"] = "needs_reimport"
428
- status["reason"] = "it was imported without images it uses, which were not imported yet"
429
- status["imported_without"] = without
430
- return status
501
+ if without.is_empty():
502
+ return {}
503
+ var built: Dictionary = _stale("it was imported without images it uses, which were not imported yet")
504
+ built["imported_without"] = without
505
+ return built
431
506
 
432
507
 
433
- ## The hash of its source that [param resource_path]'s last import recorded, or "" when there is no
434
- ## record: the editor keeps it beside the outputs, under the same base name with [code].md5[/code].
435
- static func _recorded_source_md5(resource_path: String) -> String:
508
+ ## The hashes [param resource_path]'s last import recorded, [code]source_md5[/code] of its source
509
+ ## and [code]dest_md5[/code] of its outputs, empty when there is no record: the editor keeps them
510
+ ## beside the outputs, under the same base name with [code].md5[/code].
511
+ static func _recorded_md5s(resource_path: String) -> Dictionary:
436
512
  var record: String = (
437
513
  "res://.godot/imported/%s-%s.md5" % [resource_path.get_file(), resource_path.md5_text()]
438
514
  )
439
515
  var config: ConfigFile = ConfigFile.new()
440
516
  if config.load(record) != OK:
517
+ return {}
518
+ return {
519
+ "source_md5": str(config.get_value("", "source_md5", "")),
520
+ "dest_md5": str(config.get_value("", "dest_md5", "")),
521
+ }
522
+
523
+
524
+ ## One md5 over the contents of [param paths] in turn, as [code]FileAccess::get_multiple_md5[/code]
525
+ ## computes the [code]dest_md5[/code] an import records.
526
+ static func _md5_of_all(paths: Array[String]) -> String:
527
+ var hashing: HashingContext = HashingContext.new()
528
+ if hashing.start(HashingContext.HASH_MD5) != OK:
441
529
  return ""
442
- return str(config.get_value("", "source_md5", ""))
530
+ for path: String in paths:
531
+ var file: FileAccess = FileAccess.open(path, FileAccess.READ)
532
+ if file == null:
533
+ continue
534
+ while not file.eof_reached():
535
+ var chunk: PackedByteArray = file.get_buffer(32768)
536
+ if chunk.is_empty():
537
+ break
538
+ if hashing.update(chunk) != OK:
539
+ return ""
540
+ return hashing.finish().hex_encode()
541
+
542
+
543
+ ## The md5 of each resource's sidecar as the editor last imported it, keyed by path, from the cache
544
+ ## the editor writes under .godot/editor; empty when there is no cache or it cannot be read.
545
+ func _imported_sidecar_md5s() -> Dictionary:
546
+ if _sidecar_md5s != null:
547
+ return _sidecar_md5s
548
+ var found: Dictionary = {}
549
+ _sidecar_md5s = found
550
+ var cache: String = _editor_cache_path()
551
+ var file: FileAccess = null if cache.is_empty() else FileAccess.open(cache, FileAccess.READ)
552
+ if file == null:
553
+ return found
554
+ # The first line is the import settings version; then a "::<dir>::<time>" line opens each
555
+ # directory, and each file line is nine fields split by "::", the last holding the file's
556
+ # dependencies, which may contain the splitter. The eighth field is "<>"-separated, with the
557
+ # sidecar's md5 sixth.
558
+ var directory: String = "res://"
559
+ var first: bool = true
560
+ while not file.eof_reached():
561
+ var line: String = file.get_line().strip_edges()
562
+ if first:
563
+ first = false
564
+ continue
565
+ if line.is_empty():
566
+ continue
567
+ if line.begins_with("::"):
568
+ var opened: PackedStringArray = line.split("::")
569
+ if opened.size() == 3:
570
+ directory = opened[1]
571
+ continue
572
+ var fields: PackedStringArray = line.split("::", true, 8)
573
+ if fields.size() < 9:
574
+ continue
575
+ var slices: PackedStringArray = fields[7].split("<>")
576
+ if slices.size() >= 7 and not slices[5].is_empty():
577
+ found[directory.path_join(fields[0])] = slices[5]
578
+ return found
579
+
580
+
581
+ ## The editor's filesystem cache, the newest version of it when an engine upgrade has left an older
582
+ ## one beside it, or "" when the project has none. Its name carries a format version that the
583
+ ## engine raises when the layout changes: 4.7.2 writes filesystem_cache10.
584
+ static func _editor_cache_path() -> String:
585
+ var newest: int = -1
586
+ if not DirAccess.dir_exists_absolute(EDITOR_DIRECTORY):
587
+ return ""
588
+ for name: String in DirAccess.get_files_at(EDITOR_DIRECTORY):
589
+ if name.begins_with(EDITOR_CACHE_PREFIX) and name.trim_prefix(EDITOR_CACHE_PREFIX).is_valid_int():
590
+ newest = maxi(newest, name.trim_prefix(EDITOR_CACHE_PREFIX).to_int())
591
+ return "" if newest < 0 else EDITOR_DIRECTORY.path_join(EDITOR_CACHE_PREFIX + str(newest))
592
+
593
+
594
+ ## The path of one entry [method ResourceLoader.get_dependencies] lists, past any uid and type.
595
+ static func _path_of(entry: String) -> String:
596
+ var at: int = entry.rfind("::")
597
+ return entry if at < 0 else entry.substr(at + 2)
443
598
 
444
599
 
445
- ## Whether [param dependencies], as [method ResourceLoader.get_dependencies] lists them, include
446
- ## [param path]; each entry may carry a uid and a type ahead of the path.
447
- static func _names(dependencies: PackedStringArray, path: String) -> bool:
448
- for entry: String in dependencies:
449
- if entry == path or entry.ends_with("::" + path):
450
- return true
451
- return false
600
+ ## Every path [param resource_path] depends on, through the resources it depends on in turn; a set,
601
+ ## keyed by path.
602
+ static func _depended_on(resource_path: String) -> Dictionary[String, bool]:
603
+ var found: Dictionary[String, bool] = {}
604
+ var pending: Array[String] = [resource_path]
605
+ # Every path is followed once, which ends the walk and survives a cycle; a cap or a list of
606
+ # extensions would leave out a material behind a long chain or in a saved .mesh.
607
+ while not pending.is_empty():
608
+ var next: String = pending.pop_back()
609
+ for entry: String in ResourceLoader.get_dependencies(next):
610
+ var path: String = _path_of(entry)
611
+ if not found.has(path):
612
+ found[path] = true
613
+ pending.append(path)
614
+ return found
615
+
616
+
617
+ ## Whether the import [param sidecar] describes runs a post-import script.
618
+ static func _shaped_by_a_script(sidecar: ConfigFile) -> bool:
619
+ return not str(sidecar.get_value("params", "import_script/path", "")).is_empty()
452
620
 
453
621
 
454
622
  ## The files an import wrote, from its sidecar's [code]dest_files[/code]; empty when it lists none.
455
- static func _outputs_of(import_file_path: String) -> Array[String]:
456
- var config: ConfigFile = ConfigFile.new()
623
+ static func _outputs_of(sidecar: ConfigFile) -> Array[String]:
457
624
  var outputs: Array[String] = []
458
- if config.load(import_file_path) != OK:
459
- return outputs
460
- var listed: Variant = config.get_value("deps", "dest_files", [])
625
+ var listed: Variant = sidecar.get_value("deps", "dest_files", [])
461
626
  if listed is Array:
462
627
  for one: Variant in listed:
463
628
  outputs.append(str(one))