@tsplc/build 0.2.6 → 0.2.7

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.
package/python/build.py CHANGED
@@ -15,6 +15,8 @@ except NameError:
15
15
  import shutil
16
16
  import time
17
17
 
18
+ from hardware import apply_controller, configure_device_tree, import_device_description, verify_pinned
19
+
18
20
 
19
21
  class CompilerError(RuntimeError):
20
22
  def __init__(self, diagnostics):
@@ -98,14 +100,18 @@ def object_key(spec):
98
100
  tuple(object_key(child) for child in spec.get("children", [])))
99
101
 
100
102
 
101
- def project_key(request):
103
+ def template_key(request):
104
+ """Everything a build imports into besides its source objects."""
102
105
  return (request["templateHash"], request["entry"], request["task"],
103
106
  tuple(request["removeObjects"]), request["symbols"], tuple(request.get("libraryReferences", [])),
104
107
  json.dumps(request.get("deviceTree"), sort_keys=True),
105
108
  json.dumps(request.get("controller"), sort_keys=True),
106
109
  tuple((item["title"],item["sha256"],item.get("namespace")) for item in request.get("libraries", [])),
107
- tuple((task["name"], task["intervalNanoseconds"], task["priority"], tuple(task["programs"])) for task in request.get("tasks", [])),
108
- tuple(object_key(spec) for spec in request["objects"]))
110
+ tuple((task["name"], task["intervalNanoseconds"], task["priority"], tuple(task["programs"])) for task in request.get("tasks", [])))
111
+
112
+
113
+ def project_key(request):
114
+ return template_key(request) + (tuple(object_key(spec) for spec in request["objects"]),)
109
115
 
110
116
 
111
117
  def unique_child(parent, name):
@@ -150,14 +156,21 @@ def create_object(engine, parent, spec):
150
156
  raise RuntimeError("Unsupported IEC object kind " + kind)
151
157
 
152
158
 
153
- def import_object(engine, parent, spec):
159
+ def import_object(engine, parent, spec, previous):
160
+ """Writes the object's text unless `previous`, the spec the project already holds, has the same.
161
+ Each write costs a parse, and a compiler model that sees nothing changed builds and transfers less.
162
+ Returns how many objects were written."""
154
163
  obj = unique_child(parent, spec["name"])
155
164
  if obj is None:
156
165
  obj = create_object(engine, parent, spec)
157
- if spec["kind"] != "action":
158
- obj.textual_declaration.replace(spec["declaration"])
159
- if "implementation" in spec:
160
- obj.textual_implementation.replace(spec["implementation"])
166
+ previous = None
167
+ written = 0
168
+ if previous is None or any(previous.get(key) != spec.get(key) for key in ("declaration", "implementation")):
169
+ if spec["kind"] != "action":
170
+ obj.textual_declaration.replace(spec["declaration"])
171
+ if "implementation" in spec:
172
+ obj.textual_implementation.replace(spec["implementation"])
173
+ written = 1
161
174
  children = spec.get("children", [])
162
175
  if spec["kind"] == "property":
163
176
  # Remove default accessors absent from source (e.g. a read-only property).
@@ -165,8 +178,10 @@ def import_object(engine, parent, spec):
165
178
  for child in list(obj.get_children()):
166
179
  if child.get_name().lower() in ("get", "set") and child.get_name().lower() not in names:
167
180
  child.remove()
181
+ held = dict((child["name"].lower(), child) for child in previous.get("children", [])) if previous else {}
168
182
  for child in children:
169
- import_object(engine, obj, child)
183
+ written += import_object(engine, obj, child, held.get(child["name"].lower()))
184
+ return written
170
185
 
171
186
 
172
187
  def install_project_libraries(engine, application, libraries, progress):
@@ -215,14 +230,62 @@ def install_project_libraries(engine, application, libraries, progress):
215
230
  refs[0].namespace = namespace
216
231
 
217
232
 
218
- def import_objects(engine, application, request, reusing, progress):
233
+ def same_kind(old, fresh):
234
+ return old["kind"] == fresh["kind"] and bool(old.get("persistent")) == bool(fresh.get("persistent"))
235
+
236
+
237
+ def remove_deleted(parent, previous, current, gone):
238
+ """Reconcile only source-owned objects; preserve GUIDs of unchanged objects."""
239
+ new = {item["name"].lower(): item for item in current}
240
+ for old in previous:
241
+ obj = unique_child(parent, old["name"])
242
+ fresh = new.get(old["name"].lower())
243
+ if obj is None:
244
+ raise RuntimeError("Saved source object is missing: " + old["name"])
245
+ if fresh is None:
246
+ gone.append(obj)
247
+ elif not same_kind(old, fresh):
248
+ obj.remove()
249
+ else:
250
+ remove_deleted(obj, old.get("children", []), fresh.get("children", []), gone)
251
+
252
+
253
+ def reconcile(application, previous, current):
254
+ """Removes the source objects the import recreates: those whose kind changed, and those moved to
255
+ another folder. Returns the objects gone from the source. Remove them once the import has rewritten
256
+ what used them: CODESYS fails ("Value cannot be null") removing a type or POU that is still in use."""
257
+ new = {(tuple(item["folder"]), item["name"].lower()): item for item in current}
258
+ names = set(item["name"].lower() for item in current)
259
+ gone = []
260
+ for old in previous:
261
+ parent = application
262
+ for name in old["folder"]:
263
+ parent = unique_child(parent, name)
264
+ if parent is None:
265
+ raise RuntimeError("Saved source folder is missing: " + name)
266
+ fresh = new.get((tuple(old["folder"]), old["name"].lower()))
267
+ if fresh is None and old["name"].lower() in names:
268
+ # A folder move changes placement rather than text. Remove/recreate just
269
+ # that moved object, letting the vendor decide whether the update is valid.
270
+ moved = unique_child(parent, old["name"])
271
+ if moved is None:
272
+ raise RuntimeError("Saved source object is missing: " + old["name"])
273
+ moved.remove()
274
+ else:
275
+ remove_deleted(parent, [old], [fresh] if fresh else [], gone)
276
+ return gone
277
+
278
+
279
+ def import_objects(engine, application, request, previous, progress):
280
+ """`previous` lists the source objects the project already holds, reconciled with this request;
281
+ None imports every object afresh."""
219
282
  install_project_libraries(engine, application, request.get("libraries", []), progress)
220
283
  if request.get("libraryReferences"):
221
284
  manager = application.get_library_manager()
222
285
  for reference in request["libraryReferences"]:
223
286
  manager.add_library(reference)
224
287
  progress("Importing source objects")
225
- if not reusing:
288
+ if previous is None:
226
289
  for name in request["removeObjects"]:
227
290
  for obj in application.find_ignore_transient_objects(name, True):
228
291
  obj.remove()
@@ -233,6 +296,8 @@ def import_objects(engine, application, request, reusing, progress):
233
296
  raise RuntimeError("Ambiguous IEC object " + spec["name"])
234
297
  for obj in existing:
235
298
  obj.remove()
299
+ held = dict(((tuple(item["folder"]), item["name"].lower()), item) for item in previous or [])
300
+ written = 0
236
301
  for spec in request["objects"]:
237
302
  parent = application
238
303
  for name in spec["folder"]:
@@ -246,7 +311,8 @@ def import_objects(engine, application, request, reusing, progress):
246
311
  elif not folder.is_folder:
247
312
  raise RuntimeError("Source folder conflicts with IEC object " + name)
248
313
  parent = folder
249
- import_object(engine, parent, spec)
314
+ written += import_object(engine, parent, spec, held.get((tuple(spec["folder"]), spec["name"].lower())))
315
+ progress("Wrote {0} source objects".format(written))
250
316
  if "tasks" in request:
251
317
  return configure_tasks(engine, application, request["tasks"], request["objects"])
252
318
  matches = application.find_ignore_transient_objects(request["task"], True)
@@ -337,268 +403,34 @@ def configure_tasks(engine, application, tasks, objects):
337
403
  return tasks
338
404
 
339
405
 
340
- # CODESYS EDS Import's converter factory; plain import_device expects device XML.
341
- EDS_CONVERTER = "1ce4a9c1-37d3-496c-9e80-cd99ad3807ee"
342
- ETHERNET = ("Ethernet", 110, "0000 0002", "4.2.0.0")
343
- SCANNER = ("EtherNet_IP_Scanner", 100, "0000 100B", "4.9.0.0")
344
- # Ethernet adapter interface settings and EtherNet/IP adapter parameter IDs.
345
- # The interface name is a STRING (10) and a WSTRING (12); the device editor and runtime read the WSTRING.
346
- ETHERNET_PARAMETERS = (("address", 0), ("subnet", 1), ("gateway", 2), ("interface", 10), ("interface", 12))
347
- TARGET_ADDRESS = 256
348
- CONNECTION_SETTINGS = 268435457
349
- MODBUS_CLIENT = ("Modbus_TCP_Client", 88, "0000 0003", "4.6.0.0")
350
- MODBUS_SERVER = (89, "0000 0005", "4.6.0.0")
351
- MODBUS_EDITOR = r"C:\CODESYS-3.5.22.30\CODESYS\PlugIns\7e343689-9a5f-46aa-bf56-5903c4efec59\4.6.0.0\DeviceEditorModbus.plugin.dll"
352
- # Modbus client and server parameter IDs.
353
- MODBUS_AUTO_RECONNECT = 9002
354
- MODBUS_CHANNEL_STATUS = 9003
355
- MODBUS_ADDRESS = 9102
356
- MODBUS_PORT = 9103
357
- # The editor's parameter IDs for channel number n: n * 2^24 plus these.
358
- MODBUS_CHANNEL = 1 << 24
359
- MODBUS_TRIGGER = 0x200001
360
- MODBUS_STATUS = 0x200002
361
- MODBUS_REQUESTS = 0x200003
362
- MODBUS_HOLDING_WRITE = 0x840000
363
- MODBUS_WRITE_SINGLE_REGISTER = 6
364
- MODBUS_RISING_EDGE = 6
365
-
366
-
367
- def host_parameters(device):
368
- connectors = [connector for connector in device.connectors if len(list(connector.host_parameters))]
369
- if len(connectors) != 1:
370
- raise RuntimeError("Expected one configurable connector on " + device.get_name())
371
- return dict((parameter.id, parameter) for parameter in connectors[0].host_parameters)
372
-
373
-
374
- def child_device(engine, parent, spec):
375
- name, kind, identifier, version = spec
376
- parent.add(name, kind, identifier, version)
377
- matches = [child for child in parent.find(name, False) if getattr(child, "is_device", False)]
378
- if len(matches) != 1:
379
- raise RuntimeError("Could not add device " + name)
380
- return matches[0]
381
-
382
-
383
- def set_parameter(parameter, value):
384
- parameter.value = value
385
- if unicode(parameter.value).replace(" ", "") != unicode(value).replace(" ", ""):
386
- raise RuntimeError("{0} reads back {1}, not {2}".format(parameter.visible_name, parameter.value, value))
387
-
388
-
389
- def string_literal(parameter, text):
390
- return ('"{0}"' if parameter.type_string == "WSTRING" else "'{0}'").format(text)
391
-
392
-
393
- def ip_array(address, hexadecimal=False):
394
- octets = [int(octet) for octet in address.split(".")]
395
- return "[" + ",".join("16#{0:02X}".format(octet) if hexadecimal else str(octet) for octet in octets) + "]"
396
-
397
-
398
- def pin(channel, address):
399
- # CODESYS validates a manual address against the automatic one until it is disabled.
400
- channel.io_mapping.manual_iec_address = address
401
- channel.io_mapping.automatic_iec_address = False
402
-
403
-
404
- def settle(pinned):
405
- """Pin only channels whose automatic address differs; each write revalidates the whole tree."""
406
- for address, channel in sorted(pinned.items()):
407
- # An automatic mapping reports its assigned address here.
408
- if channel.io_mapping.manual_iec_address != address:
409
- pin(channel, address)
410
-
411
-
412
- def pin_channels(engine, adapter, parameters, device):
413
- """Give each raw payload byte the address of its offset, so AT declarations read it."""
414
- pinned = {}
415
- for direction, region, kind in (("inputs", "I", engine.ChannelType.Input), ("outputs", "Q", engine.ChannelType.Output)):
416
- channels = sorted((parameter for parameter in parameters.values() if parameter.channel_type == kind), key=lambda parameter: parameter.id)
417
- area = device.get(direction)
418
- if not area:
419
- if channels:
420
- raise RuntimeError("{0} has {1} without a configured address".format(device["name"], direction))
421
- continue
422
- if len(channels) != area["size"] or any(channel.bit_size != 8 for channel in channels):
423
- raise RuntimeError("{0} {1} are not {2} raw byte channels".format(device["name"], direction, area["size"]))
424
- base = int(area["address"][3:])
425
- # Later automatic channels follow the first one consecutively.
426
- pin(channels[0], "%{0}B{1}".format(region, base))
427
- for offset, channel in enumerate(channels):
428
- pinned["%{0}B{1}".format(region, base + offset)] = channel
429
- settle(pinned)
430
- return pinned
431
-
432
-
433
- def channel_at(pinned, address):
434
- if address in pinned:
435
- return pinned[address]
436
- match = re.match(r"^%([IQ])(X|B)(\d+)(?:\.([0-7]))?$", address)
437
- channel = pinned.get("%{0}B{1}".format(match.group(1), match.group(3)))
438
- if channel is None:
439
- raise RuntimeError("No generated channel at " + address)
440
- return list(channel)[int(match.group(4))] if match.group(2) == "X" else channel
441
-
442
-
443
- def map_channels(pinned, tree, application):
444
- """Map bound bits and bytes to their variables; comment the channels of IO that keeps AT."""
445
- mapped = set()
446
- for mapping in tree.get("mappings", []):
447
- channel_at(pinned, mapping["address"]).io_mapping.variable = application + "." + mapping["variable"]
448
- mapped.add(mapping["address"])
449
- for device in tree["devices"]:
450
- for comment in device.get("comments", []):
451
- if comment["address"] not in mapped:
452
- channel_at(pinned, comment["address"]).user_comment = comment["text"]
453
-
454
-
455
- def add_modbus_channel(project, server, register):
456
- """Add a write channel with the editor's own routine: CODESYS scripting has no API for channels."""
457
- from System import Activator, Array, Enum, Object, UInt16, UInt32
458
- from System.Reflection import Assembly, BindingFlags
459
- flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static
460
- editor = Assembly.LoadFrom(MODBUS_EDITOR)
461
- def named(name):
462
- return editor.GetType("_3S.CoDeSys.DeviceEditorModbus." + name, True)
463
- def call(owner, target, name, *arguments):
464
- return owner.GetMethod(name, flags).Invoke(target, Array[Object](list(arguments)))
465
- manager = named("APEnvironment").GetProperty("ObjectMgr", flags).GetValue(None, None)
466
- meta = manager.GetObjectToModify(project.handle, server.guid)
467
- connector = [item for item in meta.Object.Connectors if str(item.ConnectorRole) == "Child"][0]
468
- data = call(named("New.ChannelDataBase"), None, "Create", connector)
469
- item_type = named("ModbusChannelItem")
470
- item = Activator.CreateInstance(item_type)
471
- fields = (
472
- ("stName", register["name"]),
473
- ("usFunctionCode", UInt16(MODBUS_WRITE_SINGLE_REGISTER)),
474
- ("usWriteOffset", UInt16(register["offset"])),
475
- ("usWriteLength", UInt16(1)),
476
- ("usReadLength", UInt16(0)),
477
- ("triggerType", Enum.ToObject(named("TriggerType"), MODBUS_RISING_EDGE)),
478
- ("uiParamID", call(data.GetType(), data, "GetNextAvailableParamID", call(data.GetType(), data, "LoadSlaveChannels", connector))),
479
- )
480
- for name, value in fields:
481
- item_type.GetField(name).SetValue(item, value)
482
- factory = call(named("New.PresetFactory"), None, "Create", connector, False)
483
- call(data.GetType(), data, "CreateSlaveChannel", connector, item, factory)
484
- manager.SetObject(meta, True, None)
485
- return int(UInt32(item_type.GetField("uiParamID").GetValue(item))) // MODBUS_CHANNEL
486
-
487
-
488
- def configure_modbus(engine, project, ethernet, devices):
489
- """One Modbus TCP server per card with registers, with channels pinned like the EtherNet/IP bytes."""
490
- pinned = {}
491
- client = child_device(engine, ethernet, MODBUS_CLIENT)
492
- parameters = host_parameters(client)
493
- # The line runs unattended, so a card that restarts must come back on its own.
494
- set_parameter(parameters[MODBUS_AUTO_RECONNECT], "TRUE")
495
- # Status and request count are how the program learns a write finished.
496
- set_parameter(parameters[MODBUS_CHANNEL_STATUS], "TRUE")
497
- for device in devices:
498
- server = child_device(engine, client, (device["name"] + "_Modbus",) + MODBUS_SERVER)
499
- server.driver_info.always_update_variables = engine.AlwaysUpdateVariablesMode.OnlyIfUnused
500
- parameters = host_parameters(server)
501
- set_parameter(parameters[MODBUS_ADDRESS], ip_array(device["address"]))
502
- set_parameter(parameters[MODBUS_PORT], str(device["modbus"]["port"]))
503
- for register in device["modbus"]["registers"]:
504
- channel = add_modbus_channel(project, server, register) * MODBUS_CHANNEL
505
- parameters = host_parameters(server)
506
- pinned[register["write"]] = parameters[channel + MODBUS_TRIGGER]
507
- pinned[register["status"]] = parameters[channel + MODBUS_STATUS]
508
- pinned[register["requests"]] = parameters[channel + MODBUS_REQUESTS]
509
- pinned[register["value"]] = parameters[channel + MODBUS_HOLDING_WRITE + register["offset"]]
510
- settle(pinned)
511
- return pinned
512
-
513
-
514
- def template_controller(project):
515
- controllers = [child for child in project.get_children() if getattr(child, "is_device", False)]
516
- if len(controllers) != 1:
517
- raise RuntimeError("The template must contain one controller device")
518
- return controllers[0]
519
-
520
-
521
- def apply_controller(project, identity, progress):
522
- controller = template_controller(project)
523
- wanted = (identity["type"], identity["id"], identity["version"])
524
- current = controller.get_device_identification()
525
- if (current.type, current.id, current.version) == wanted:
526
- return
527
- progress("Updating controller to {0} {1} {2}".format(*wanted))
528
- # Update Device keeps the Application and its children under the new device.
529
- controller.update(*wanted)
530
- updated = controller.get_device_identification()
531
- if (updated.type, updated.id, updated.version) != wanted:
532
- raise RuntimeError("CODESYS kept controller {0} {1} {2}; install the device description for {3} {4} {5}".format(
533
- updated.type, updated.id, updated.version, *wanted))
534
-
535
-
536
- def import_device_description(engine, device, root):
537
- from System import Guid
538
- repository = engine.device_repository
539
- identity = repository.import_device(os.path.join(root, device["eds"]), repository.sources[0], Guid(EDS_CONVERTER), True)
540
- if identity is None:
541
- raise RuntimeError("CODESYS could not import the EDS of " + device["name"])
542
- return identity
543
-
544
-
545
- def configure_device_tree(engine, project, tree, root, progress):
546
- controller = template_controller(project)
547
- for child in controller.get_children(True):
548
- if getattr(child, "is_device", False) and child.get_device_identification().type in (ETHERNET[1], SCANNER[1]):
549
- raise RuntimeError("Template already configures " + child.get_name() + "; remove it or codesys.deviceTree")
550
- progress("Generating EtherNet/IP device tree")
551
- ethernet = child_device(engine, controller, ETHERNET)
552
- parameters = host_parameters(ethernet)
553
- for key, identifier in ETHERNET_PARAMETERS:
554
- value = tree.get("ethernet", {}).get(key)
555
- if value is not None:
556
- set_parameter(parameters[identifier], string_literal(parameters[identifier], value) if key == "interface" else ip_array(value))
557
- scanner = child_device(engine, ethernet, SCANNER)
558
- pinned = {}
559
- for device in tree["devices"]:
560
- identity = import_device_description(engine, device, root)
561
- adapter = child_device(engine, scanner, (device["name"], identity.type, identity.id, identity.version))
562
- # CODESYS otherwise skips mapped variables no IEC code reads, such as inputs only clients observe.
563
- adapter.driver_info.always_update_variables = engine.AlwaysUpdateVariablesMode.OnlyIfUnused
564
- parameters = host_parameters(adapter)
565
- if device.get("address"):
566
- set_parameter(parameters[TARGET_ADDRESS], ip_array(device["address"], True))
567
- if device.get("rpiMicroseconds"):
568
- fields = [field for field in parameters[CONNECTION_SETTINGS] if field.identifier == "RPI"]
569
- set_parameter(fields[0], str(device["rpiMicroseconds"]))
570
- for address, channel in pin_channels(engine, adapter, parameters, device).items():
571
- if address in pinned:
572
- raise RuntimeError("{0} overlaps another adapter".format(address))
573
- pinned[address] = channel
574
- registers = [device for device in tree["devices"] if device.get("modbus")]
575
- if registers:
576
- progress("Generating Modbus TCP devices")
577
- pinned.update(configure_modbus(engine, project, ethernet, registers))
578
- # Adding a later adapter can reflow earlier automatic addresses.
579
- settle(pinned)
580
- map_channels(pinned, tree, project.active_application.get_name())
581
- return pinned
582
-
583
-
584
- def verify_pinned(pinned):
585
- for address, channel in pinned.items():
586
- if channel.io_mapping.manual_iec_address != address:
587
- raise RuntimeError("Channel {0} moved to {1}".format(address, channel.io_mapping.manual_iec_address))
588
-
589
-
590
- def device_tree_cache(request, workspace):
591
- """The project as it stands once its device tree is generated, kept beside the worker, which each
592
- container starts afresh. Generating the tree takes most of a build, and the same template,
593
- controller, tree and generator always produce the same one."""
594
- with io.open(__file__, "rb") as source:
595
- generator = hashlib.sha256(source.read()).hexdigest()
596
- inputs = json.dumps([generator, request["templateHash"], request.get("controller"), request["deviceTree"]], sort_keys=True)
597
- return os.path.join(os.path.dirname(workspace), "device-tree", hashlib.sha256(inputs.encode("utf-8")).hexdigest() + ".project")
406
+ def scripts_hash(*names):
407
+ """A cached project is only as current as the scripts that made it."""
408
+ digest = hashlib.sha256()
409
+ for name in names:
410
+ with io.open(os.path.join(os.path.dirname(__file__), name), "rb") as source:
411
+ digest.update(source.read())
412
+ return digest.hexdigest()
413
+
414
+
415
+ def cache_path(directory, kind, scripts, inputs):
416
+ key = hashlib.sha256(json.dumps([scripts_hash(*scripts)] + inputs, sort_keys=True).encode("utf-8")).hexdigest()
417
+ return os.path.join(directory, kind, key + ".project")
418
+
419
+
420
+ def device_tree_cache(request, directory):
421
+ """The project as it stands once its device tree is generated. Generating the tree takes most of
422
+ a build, and the same template, controller, tree and hardware.py always produce the same one."""
423
+ return cache_path(directory, "device-tree", ["hardware.py"], [request["templateHash"], request.get("controller"), request["deviceTree"]])
424
+
425
+
426
+ def build_cache(request, directory):
427
+ """The last successful build's project, beside the source objects it holds (`.json`). Most builds
428
+ change a few objects, and writing only those is much faster than importing every one."""
429
+ return cache_path(directory, "last-build", ["build.py", "hardware.py"], list(template_key(request)))
598
430
 
599
431
 
600
432
  def keep_device_tree(pending, cache):
601
- """Replace the previous tree once a build with this one succeeds, so the cache holds one verified tree."""
433
+ """Replace the previous tree once a project with this one works, so the cache holds one verified tree."""
602
434
  directory = os.path.dirname(cache)
603
435
  for name in os.listdir(directory):
604
436
  if os.path.join(directory, name) != pending:
@@ -606,22 +438,33 @@ def keep_device_tree(pending, cache):
606
438
  os.rename(pending, cache)
607
439
 
608
440
 
609
- def open_project(engine, request, workspace, progress):
610
- """Opens the template with its controller and device tree, from the cache when the tree's inputs match it.
611
- Returns the project, the channels it pinned, and a newly generated tree to keep if the build succeeds."""
612
- working = os.path.join(workspace, "project")
613
- if os.path.isdir(working):
614
- shutil.rmtree(working)
615
- os.mkdir(working)
616
- path = os.path.join(working, "Application.project")
441
+ def keep_build(project, objects, cache):
442
+ directory = os.path.dirname(cache)
443
+ if os.path.isdir(directory):
444
+ shutil.rmtree(directory)
445
+ os.makedirs(directory)
446
+ # The project appears last: a cache without its objects would import into unknown source.
447
+ with io.open(os.path.splitext(cache)[0] + ".json", "w", encoding="utf-8") as output:
448
+ output.write(u"{0}".format(json.dumps(objects, ensure_ascii=False)))
449
+ shutil.copyfile(project, cache + ".pending")
450
+ os.rename(cache + ".pending", cache)
451
+
452
+
453
+ def describe_devices(engine, tree, root):
454
+ """A saved tree's adapters need their descriptions in this container's device repository."""
455
+ for device in tree["devices"] if tree else []:
456
+ import_device_description(engine, device, root)
457
+
458
+
459
+ def open_template(engine, request, path, devices, caches, progress):
460
+ """Opens the template as `path` with its controller and device tree, whose descriptions are in
461
+ `devices`; from the cache in `caches` when it holds this tree. Returns the project, the channels
462
+ it pinned, and a newly generated tree to keep once the project works."""
617
463
  tree = request.get("deviceTree")
618
- cache = device_tree_cache(request, workspace) if tree else None
464
+ cache = device_tree_cache(request, caches) if tree else None
619
465
  if cache and os.path.isfile(cache):
620
466
  progress("Reusing the generated device tree")
621
- root = os.path.dirname(request["receipt"])
622
- # The tree's adapters need their descriptions in this container's device repository.
623
- for device in tree["devices"]:
624
- import_device_description(engine, device, root)
467
+ describe_devices(engine, tree, devices)
625
468
  shutil.copyfile(cache, path)
626
469
  return engine.projects.open(path, primary=True), {}, None
627
470
  progress("Opening project")
@@ -631,7 +474,7 @@ def open_project(engine, request, workspace, progress):
631
474
  apply_controller(project, request["controller"], progress)
632
475
  if not tree:
633
476
  return project, {}, None
634
- pinned = configure_device_tree(engine, project, tree, os.path.dirname(request["receipt"]), progress)
477
+ pinned = configure_device_tree(engine, project, tree, devices, progress)
635
478
  project.save()
636
479
  if not os.path.isdir(os.path.dirname(cache)):
637
480
  os.makedirs(os.path.dirname(cache))
@@ -640,6 +483,29 @@ def open_project(engine, request, workspace, progress):
640
483
  return project, pinned, (pending, cache)
641
484
 
642
485
 
486
+ def open_project(engine, request, workspace, progress):
487
+ """Opens the project to import into: the last build's when only source objects changed since it,
488
+ else the template. Returns the project, the source objects it already holds (None for none),
489
+ the channels it pinned, and a newly generated tree to keep if the build succeeds."""
490
+ working = os.path.join(workspace, "project")
491
+ if os.path.isdir(working):
492
+ shutil.rmtree(working)
493
+ os.mkdir(working)
494
+ path = os.path.join(working, "Application.project")
495
+ # Caches live beside the worker, which each container starts afresh.
496
+ caches, devices = os.path.dirname(workspace), os.path.dirname(request["receipt"])
497
+ last = build_cache(request, caches)
498
+ if os.path.isfile(last):
499
+ progress("Reusing the last build's project")
500
+ describe_devices(engine, request.get("deviceTree"), devices)
501
+ with io.open(os.path.splitext(last)[0] + ".json", encoding="utf-8") as source:
502
+ previous = json.load(source)
503
+ shutil.copyfile(last, path)
504
+ return engine.projects.open(path, primary=True), previous, {}, None
505
+ project, pinned, generated = open_template(engine, request, path, devices, caches, progress)
506
+ return project, None, pinned, generated
507
+
508
+
643
509
  def build(engine, request, loaded, workspace):
644
510
  progress_path = os.path.join(os.path.dirname(request["receipt"]), "progress.log")
645
511
 
@@ -652,24 +518,26 @@ def build(engine, request, loaded, workspace):
652
518
  # Reopen when the template or object membership changes, so removed/renamed
653
519
  # declarations cannot survive from an earlier build. Text changes are imported below.
654
520
  key = project_key(request)
655
- reusing = loaded.get("key") == key
656
521
  project = None
657
522
  # Only a tree generated in this build has channels to verify; a reused or cached one passed when it was made.
658
523
  pinned, generated = {}, None
659
524
  try:
660
- if reusing:
525
+ if loaded.get("key") == key:
661
526
  progress("Reusing loaded project")
662
- project = loaded["project"]
527
+ project, previous = loaded["project"], loaded["objects"]
663
528
  else:
664
529
  if loaded.get("project") is not None:
665
530
  loaded["project"].close()
666
531
  loaded.clear()
667
- project, pinned, generated = open_project(engine, request, workspace, progress)
532
+ project, previous, pinned, generated = open_project(engine, request, workspace, progress)
668
533
  loaded.clear()
669
534
  application = project.active_application
670
535
  if application is None:
671
536
  raise RuntimeError("Template must have an active Application")
672
- configured_tasks = import_objects(engine, application, request, reusing, progress)
537
+ gone = reconcile(application, previous, request["objects"]) if previous is not None else []
538
+ configured_tasks = import_objects(engine, application, request, previous, progress)
539
+ for obj in gone:
540
+ obj.remove()
673
541
  if request["symbols"] and not any(
674
542
  getattr(child, "is_symbol_config", False)
675
543
  for child in application.get_children()
@@ -707,13 +575,14 @@ def build(engine, request, loaded, workspace):
707
575
  raise RuntimeError("Compiler did not produce a non-empty boot application")
708
576
  if generated:
709
577
  keep_device_tree(*generated)
578
+ keep_build(project.path, request["objects"], build_cache(request, os.path.dirname(workspace)))
710
579
  progress("Build complete")
711
580
  with io.open(request["receipt"], "w", encoding="utf-8") as receipt:
712
581
  receipt.write(json.dumps(
713
582
  dict({"mode": request["mode"], "application": application.get_name()}, **({"tasks": configured_tasks} if configured_tasks is not None else {})),
714
583
  ensure_ascii=False,
715
584
  ))
716
- loaded.update({"key": key, "project": project})
585
+ loaded.update({"key": key, "objects": request["objects"], "project": project})
717
586
  except Exception:
718
587
  loaded.clear()
719
588
  if project is not None: